feat(screen-designer): 그리드 컬럼 시스템 개선 및 컴포넌트 너비 렌더링 수정
주요 변경사항: - 격자 설정을 편집 탭에서 항상 표시 (해상도 설정 하단) - 그리드 컬럼 수 동적 조정 가능 (1-24) - 컴포넌트 생성 시 현재 그리드 컬럼 수 기반 자동 계산 - 컴포넌트 너비가 설정한 컬럼 수대로 정확히 표시되도록 수정 수정된 파일: - ScreenDesigner: 컴포넌트 드롭 시 gridColumns와 style.width 동적 계산 - UnifiedPropertiesPanel: 격자 설정 UI 통합, 차지 컬럼 수 설정 시 width 자동 계산 - RealtimePreviewDynamic: getWidth 우선순위 수정, DOM 크기 디버깅 로그 추가 - 8개 컴포넌트: componentStyle.width를 항상 100%로 고정 * ButtonPrimaryComponent * TextInputComponent * NumberInputComponent * TextareaBasicComponent * DateInputComponent * TableListComponent * CardDisplayComponent 문제 해결: - 컴포넌트 내부에서 component.style.width를 재사용하여 이중 축소 발생 - 해결: 부모 컨테이너(RealtimePreviewDynamic)가 width 제어, 컴포넌트는 항상 100% - 결과: 파란 테두리와 내부 콘텐츠가 동일한 크기로 정확히 표시
This commit is contained in:
parent
9f131a80ab
commit
6901baab8e
|
|
@ -51,6 +51,8 @@ class NumberingRuleService {
|
||||||
table_name AS "tableName",
|
table_name AS "tableName",
|
||||||
column_name AS "columnName",
|
column_name AS "columnName",
|
||||||
company_code AS "companyCode",
|
company_code AS "companyCode",
|
||||||
|
menu_objid AS "menuObjid",
|
||||||
|
scope_type AS "scopeType",
|
||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
created_by AS "createdBy"
|
created_by AS "createdBy"
|
||||||
|
|
@ -104,6 +106,8 @@ class NumberingRuleService {
|
||||||
table_name AS "tableName",
|
table_name AS "tableName",
|
||||||
column_name AS "columnName",
|
column_name AS "columnName",
|
||||||
company_code AS "companyCode",
|
company_code AS "companyCode",
|
||||||
|
menu_id AS "menuId",
|
||||||
|
scope_type AS "scopeType",
|
||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
created_by AS "createdBy"
|
created_by AS "createdBy"
|
||||||
|
|
@ -153,8 +157,9 @@ class NumberingRuleService {
|
||||||
const insertRuleQuery = `
|
const insertRuleQuery = `
|
||||||
INSERT INTO numbering_rules (
|
INSERT INTO numbering_rules (
|
||||||
rule_id, rule_name, description, separator, reset_period,
|
rule_id, rule_name, description, separator, reset_period,
|
||||||
current_sequence, table_name, column_name, company_code, created_by
|
current_sequence, table_name, column_name, company_code,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
menu_objid, scope_type, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
RETURNING
|
RETURNING
|
||||||
rule_id AS "ruleId",
|
rule_id AS "ruleId",
|
||||||
rule_name AS "ruleName",
|
rule_name AS "ruleName",
|
||||||
|
|
@ -165,6 +170,8 @@ class NumberingRuleService {
|
||||||
table_name AS "tableName",
|
table_name AS "tableName",
|
||||||
column_name AS "columnName",
|
column_name AS "columnName",
|
||||||
company_code AS "companyCode",
|
company_code AS "companyCode",
|
||||||
|
menu_objid AS "menuObjid",
|
||||||
|
scope_type AS "scopeType",
|
||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
created_by AS "createdBy"
|
created_by AS "createdBy"
|
||||||
|
|
@ -180,6 +187,8 @@ class NumberingRuleService {
|
||||||
config.tableName || null,
|
config.tableName || null,
|
||||||
config.columnName || null,
|
config.columnName || null,
|
||||||
companyCode,
|
companyCode,
|
||||||
|
config.menuObjid || null,
|
||||||
|
config.scopeType || "global",
|
||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -248,8 +257,10 @@ class NumberingRuleService {
|
||||||
reset_period = COALESCE($4, reset_period),
|
reset_period = COALESCE($4, reset_period),
|
||||||
table_name = COALESCE($5, table_name),
|
table_name = COALESCE($5, table_name),
|
||||||
column_name = COALESCE($6, column_name),
|
column_name = COALESCE($6, column_name),
|
||||||
|
menu_objid = COALESCE($7, menu_objid),
|
||||||
|
scope_type = COALESCE($8, scope_type),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE rule_id = $7 AND company_code = $8
|
WHERE rule_id = $9 AND company_code = $10
|
||||||
RETURNING
|
RETURNING
|
||||||
rule_id AS "ruleId",
|
rule_id AS "ruleId",
|
||||||
rule_name AS "ruleName",
|
rule_name AS "ruleName",
|
||||||
|
|
@ -260,6 +271,8 @@ class NumberingRuleService {
|
||||||
table_name AS "tableName",
|
table_name AS "tableName",
|
||||||
column_name AS "columnName",
|
column_name AS "columnName",
|
||||||
company_code AS "companyCode",
|
company_code AS "companyCode",
|
||||||
|
menu_objid AS "menuObjid",
|
||||||
|
scope_type AS "scopeType",
|
||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
created_by AS "createdBy"
|
created_by AS "createdBy"
|
||||||
|
|
@ -272,6 +285,8 @@ class NumberingRuleService {
|
||||||
updates.resetPeriod,
|
updates.resetPeriod,
|
||||||
updates.tableName,
|
updates.tableName,
|
||||||
updates.columnName,
|
updates.columnName,
|
||||||
|
updates.menuObjid,
|
||||||
|
updates.scopeType,
|
||||||
ruleId,
|
ruleId,
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -19,24 +19,7 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
||||||
onChange,
|
onChange,
|
||||||
isPreview = false,
|
isPreview = false,
|
||||||
}) => {
|
}) => {
|
||||||
if (partType === "prefix") {
|
// 1. 순번 (자동 증가)
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs font-medium sm:text-sm">접두사</Label>
|
|
||||||
<Input
|
|
||||||
value={config.prefix || ""}
|
|
||||||
onChange={(e) => onChange({ ...config, prefix: e.target.value })}
|
|
||||||
placeholder="예: PROD"
|
|
||||||
disabled={isPreview}
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
|
||||||
코드 앞에 붙을 고정 문자열
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partType === "sequence") {
|
if (partType === "sequence") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
|
@ -46,15 +29,15 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
value={config.sequenceLength || 4}
|
value={config.sequenceLength || 3}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({ ...config, sequenceLength: parseInt(e.target.value) || 4 })
|
onChange({ ...config, sequenceLength: parseInt(e.target.value) || 3 })
|
||||||
}
|
}
|
||||||
disabled={isPreview}
|
disabled={isPreview}
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||||
예: 4 → 0001, 5 → 00001
|
예: 3 → 001, 4 → 0001
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -69,11 +52,56 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
||||||
disabled={isPreview}
|
disabled={isPreview}
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||||
|
순번이 시작될 번호
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 숫자 (고정 자릿수)
|
||||||
|
if (partType === "number") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium sm:text-sm">숫자 자릿수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={config.numberLength || 4}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...config, numberLength: parseInt(e.target.value) || 4 })
|
||||||
|
}
|
||||||
|
disabled={isPreview}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||||
|
예: 4 → 0001, 5 → 00001
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium sm:text-sm">숫자 값</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={config.numberValue || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...config, numberValue: parseInt(e.target.value) || 0 })
|
||||||
|
}
|
||||||
|
disabled={isPreview}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||||
|
고정으로 사용할 숫자
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 날짜
|
||||||
if (partType === "date") {
|
if (partType === "date") {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -94,53 +122,28 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||||
);
|
현재 날짜가 자동으로 입력됩니다
|
||||||
}
|
|
||||||
|
|
||||||
if (partType === "year") {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs font-medium sm:text-sm">연도 형식</Label>
|
|
||||||
<Select
|
|
||||||
value={config.dateFormat || "YYYY"}
|
|
||||||
onValueChange={(value) => onChange({ ...config, dateFormat: value })}
|
|
||||||
disabled={isPreview}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="YYYY" className="text-xs sm:text-sm">4자리 (2025)</SelectItem>
|
|
||||||
<SelectItem value="YY" className="text-xs sm:text-sm">2자리 (25)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partType === "month") {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs font-medium sm:text-sm">월 형식</Label>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground sm:text-sm">
|
|
||||||
현재 월이 2자리 형식(01-12)으로 자동 입력됩니다
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (partType === "custom") {
|
// 4. 문자
|
||||||
|
if (partType === "text") {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs font-medium sm:text-sm">값</Label>
|
<Label className="text-xs font-medium sm:text-sm">텍스트 값</Label>
|
||||||
<Input
|
<Input
|
||||||
value={config.value || ""}
|
value={config.textValue || ""}
|
||||||
onChange={(e) => onChange({ ...config, value: e.target.value })}
|
onChange={(e) => onChange({ ...config, textValue: e.target.value })}
|
||||||
placeholder="입력값"
|
placeholder="예: PRJ, CODE, PROD"
|
||||||
disabled={isPreview}
|
disabled={isPreview}
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||||
|
고정으로 사용할 텍스트 또는 코드
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onDelete}
|
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}
|
disabled={isPreview}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
|
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
|
@ -75,8 +75,12 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="auto" className="text-xs sm:text-sm">자동 생성</SelectItem>
|
<SelectItem value="auto" className="text-xs sm:text-sm">
|
||||||
<SelectItem value="manual" className="text-xs sm:text-sm">직접 입력</SelectItem>
|
자동 생성
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="manual" className="text-xs sm:text-sm">
|
||||||
|
직접 입력
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { Plus, Save, Edit2, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule";
|
import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule";
|
||||||
|
|
@ -80,9 +81,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
const newPart: NumberingRulePart = {
|
const newPart: NumberingRulePart = {
|
||||||
id: `part-${Date.now()}`,
|
id: `part-${Date.now()}`,
|
||||||
order: currentRule.parts.length + 1,
|
order: currentRule.parts.length + 1,
|
||||||
partType: "prefix",
|
partType: "text",
|
||||||
generationMethod: "auto",
|
generationMethod: "auto",
|
||||||
autoConfig: { prefix: "CODE" },
|
autoConfig: { textValue: "CODE" },
|
||||||
};
|
};
|
||||||
|
|
||||||
setCurrentRule((prev) => {
|
setCurrentRule((prev) => {
|
||||||
|
|
@ -201,6 +202,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
separator: "-",
|
separator: "-",
|
||||||
resetPeriod: "none",
|
resetPeriod: "none",
|
||||||
currentSequence: 1,
|
currentSequence: 1,
|
||||||
|
scopeType: "global",
|
||||||
};
|
};
|
||||||
|
|
||||||
setSelectedRuleId(newRule.ruleId);
|
setSelectedRuleId(newRule.ruleId);
|
||||||
|
|
@ -342,6 +344,30 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">적용 범위</Label>
|
||||||
|
<Select
|
||||||
|
value={currentRule.scopeType || "global"}
|
||||||
|
onValueChange={(value: "global" | "menu") =>
|
||||||
|
setCurrentRule((prev) => ({ ...prev!, scopeType: value }))
|
||||||
|
}
|
||||||
|
disabled={isPreview}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="global">회사 전체</SelectItem>
|
||||||
|
<SelectItem value="menu">메뉴별</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||||
|
{currentRule.scopeType === "menu"
|
||||||
|
? "이 규칙이 설정된 상위 메뉴의 모든 하위 메뉴에서 사용 가능합니다"
|
||||||
|
: "회사 내 모든 메뉴에서 사용 가능한 전역 규칙입니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="border-border bg-card">
|
<Card className="border-border bg-card">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium">미리보기</CardTitle>
|
<CardTitle className="text-sm font-medium">미리보기</CardTitle>
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,21 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
||||||
const autoConfig = part.autoConfig || {};
|
const autoConfig = part.autoConfig || {};
|
||||||
|
|
||||||
switch (part.partType) {
|
switch (part.partType) {
|
||||||
case "prefix":
|
// 1. 순번 (자동 증가)
|
||||||
return autoConfig.prefix || "PREFIX";
|
|
||||||
|
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
const length = autoConfig.sequenceLength || 4;
|
const length = autoConfig.sequenceLength || 3;
|
||||||
const startFrom = autoConfig.startFrom || 1;
|
const startFrom = autoConfig.startFrom || 1;
|
||||||
return String(startFrom).padStart(length, "0");
|
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": {
|
case "date": {
|
||||||
const format = autoConfig.dateFormat || "YYYYMMDD";
|
const format = autoConfig.dateFormat || "YYYYMMDD";
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -54,21 +60,9 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "year": {
|
// 4. 문자
|
||||||
const now = new Date();
|
case "text":
|
||||||
const format = autoConfig.dateFormat || "YYYY";
|
return autoConfig.textValue || "TEXT";
|
||||||
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";
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return "XXX";
|
return "XXX";
|
||||||
|
|
|
||||||
|
|
@ -399,13 +399,26 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
willUse100Percent: positionX === 0,
|
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 = {
|
const componentStyle = {
|
||||||
position: "absolute" as const,
|
position: "absolute" as const,
|
||||||
...style, // 먼저 적용하고
|
...style, // 먼저 적용하고
|
||||||
left: positionX,
|
left: positionX,
|
||||||
top: position?.y || 0,
|
top: position?.y || 0,
|
||||||
// 🆕 left가 0이면 부모 너비를 100% 채우도록 수정 (우측 여백 제거)
|
width: getWidth(), // 우선순위에 따른 너비
|
||||||
width: positionX === 0 ? "100%" : (size?.width || 200),
|
|
||||||
height: finalHeight,
|
height: finalHeight,
|
||||||
zIndex: position?.z || 1,
|
zIndex: position?.z || 1,
|
||||||
// right 속성 강제 제거
|
// right 속성 강제 제거
|
||||||
|
|
|
||||||
|
|
@ -200,19 +200,58 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
|
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
|
||||||
// 너비 우선순위: style.width > size.width (픽셀값)
|
// 너비 우선순위: style.width > 조건부 100% > size.width (픽셀값)
|
||||||
const getWidth = () => {
|
const getWidth = () => {
|
||||||
// 1순위: style.width가 있으면 우선 사용
|
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
|
||||||
if (componentStyle?.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;
|
return componentStyle.width;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2순위: size.width (픽셀)
|
// 2순위: x=0인 컴포넌트는 전체 너비 사용 (버튼 제외)
|
||||||
if (component.componentConfig?.type === "table-list") {
|
const isButtonComponent =
|
||||||
return `${Math.max(size?.width || 120, 120)}px`;
|
(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 = () => {
|
const getHeight = () => {
|
||||||
|
|
@ -235,35 +274,54 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
return `${size?.height || 40}px`;
|
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 = {
|
const baseStyle = {
|
||||||
left: `${position.x}px`,
|
left: `${position.x}px`,
|
||||||
top: `${position.y}px`,
|
top: `${position.y}px`,
|
||||||
// x=0인 컴포넌트는 전체 너비 사용 (버튼 제외)
|
width: getWidth(), // getWidth()가 모든 우선순위를 처리
|
||||||
width: (position.x === 0 && !isButtonComponent) ? "100%" : getWidth(),
|
|
||||||
height: getHeight(),
|
height: getHeight(),
|
||||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||||
...componentStyle,
|
...componentStyle,
|
||||||
// x=0인 컴포넌트는 100% 너비 강제 (버튼 제외)
|
|
||||||
...(position.x === 0 && !isButtonComponent && { width: "100%" }),
|
|
||||||
right: undefined,
|
right: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🔍 DOM 렌더링 후 실제 크기 측정
|
||||||
|
const innerDivRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const outerDivRef = React.useRef<HTMLDivElement>(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) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick?.(e);
|
onClick?.(e);
|
||||||
|
|
@ -285,7 +343,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={outerDivRef}
|
||||||
id={`component-${id}`}
|
id={`component-${id}`}
|
||||||
|
data-component-id={id}
|
||||||
className="absolute cursor-pointer transition-all duration-200 ease-out"
|
className="absolute cursor-pointer transition-all duration-200 ease-out"
|
||||||
style={{ ...baseStyle, ...selectionStyle }}
|
style={{ ...baseStyle, ...selectionStyle }}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|
@ -296,10 +356,15 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
>
|
>
|
||||||
{/* 동적 컴포넌트 렌더링 */}
|
{/* 동적 컴포넌트 렌더링 */}
|
||||||
<div
|
<div
|
||||||
ref={
|
ref={(node) => {
|
||||||
component.type === "component" && (component as any).componentType === "flow-widget" ? contentRef : undefined
|
// 멀티 ref 처리
|
||||||
}
|
innerDivRef.current = node;
|
||||||
className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} w-full max-w-full overflow-visible`}
|
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%" }}
|
||||||
>
|
>
|
||||||
<DynamicComponentRenderer
|
<DynamicComponentRenderer
|
||||||
component={component}
|
component={component}
|
||||||
|
|
|
||||||
|
|
@ -2012,76 +2012,79 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const isTableList = component.id === "table-list";
|
const isTableList = component.id === "table-list";
|
||||||
|
|
||||||
// 컴포넌트 타입별 기본 그리드 컬럼 수 설정
|
// 컴포넌트 타입별 기본 그리드 컬럼 수 설정
|
||||||
|
const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수
|
||||||
let gridColumns = 1; // 기본값
|
let gridColumns = 1; // 기본값
|
||||||
|
|
||||||
// 특수 컴포넌트
|
// 특수 컴포넌트
|
||||||
if (isCardDisplay) {
|
if (isCardDisplay) {
|
||||||
gridColumns = 8;
|
gridColumns = Math.round(currentGridColumns * 0.667); // 약 66.67%
|
||||||
} else if (isTableList) {
|
} else if (isTableList) {
|
||||||
gridColumns = 12; // 테이블은 전체 너비
|
gridColumns = currentGridColumns; // 테이블은 전체 너비
|
||||||
} else {
|
} else {
|
||||||
// 웹타입별 적절한 그리드 컬럼 수 설정
|
// 웹타입별 적절한 그리드 컬럼 수 설정
|
||||||
const webType = component.webType;
|
const webType = component.webType;
|
||||||
const componentId = component.id;
|
const componentId = component.id;
|
||||||
|
|
||||||
// 웹타입별 기본 컬럼 수 매핑
|
// 웹타입별 기본 비율 매핑 (12컬럼 기준 비율)
|
||||||
const gridColumnsMap: Record<string, number> = {
|
const gridColumnsRatioMap: Record<string, number> = {
|
||||||
// 입력 컴포넌트 (INPUT 카테고리)
|
// 입력 컴포넌트 (INPUT 카테고리)
|
||||||
"text-input": 4, // 텍스트 입력 (33%)
|
"text-input": 4 / 12, // 텍스트 입력 (33%)
|
||||||
"number-input": 2, // 숫자 입력 (16.67%)
|
"number-input": 2 / 12, // 숫자 입력 (16.67%)
|
||||||
"email-input": 4, // 이메일 입력 (33%)
|
"email-input": 4 / 12, // 이메일 입력 (33%)
|
||||||
"tel-input": 3, // 전화번호 입력 (25%)
|
"tel-input": 3 / 12, // 전화번호 입력 (25%)
|
||||||
"date-input": 3, // 날짜 입력 (25%)
|
"date-input": 3 / 12, // 날짜 입력 (25%)
|
||||||
"datetime-input": 4, // 날짜시간 입력 (33%)
|
"datetime-input": 4 / 12, // 날짜시간 입력 (33%)
|
||||||
"time-input": 2, // 시간 입력 (16.67%)
|
"time-input": 2 / 12, // 시간 입력 (16.67%)
|
||||||
"textarea-basic": 6, // 텍스트 영역 (50%)
|
"textarea-basic": 6 / 12, // 텍스트 영역 (50%)
|
||||||
"select-basic": 3, // 셀렉트 (25%)
|
"select-basic": 3 / 12, // 셀렉트 (25%)
|
||||||
"checkbox-basic": 2, // 체크박스 (16.67%)
|
"checkbox-basic": 2 / 12, // 체크박스 (16.67%)
|
||||||
"radio-basic": 3, // 라디오 (25%)
|
"radio-basic": 3 / 12, // 라디오 (25%)
|
||||||
"file-basic": 4, // 파일 (33%)
|
"file-basic": 4 / 12, // 파일 (33%)
|
||||||
"file-upload": 4, // 파일 업로드 (33%)
|
"file-upload": 4 / 12, // 파일 업로드 (33%)
|
||||||
"slider-basic": 3, // 슬라이더 (25%)
|
"slider-basic": 3 / 12, // 슬라이더 (25%)
|
||||||
"toggle-switch": 2, // 토글 스위치 (16.67%)
|
"toggle-switch": 2 / 12, // 토글 스위치 (16.67%)
|
||||||
"repeater-field-group": 6, // 반복 필드 그룹 (50%)
|
"repeater-field-group": 6 / 12, // 반복 필드 그룹 (50%)
|
||||||
|
|
||||||
// 표시 컴포넌트 (DISPLAY 카테고리)
|
// 표시 컴포넌트 (DISPLAY 카테고리)
|
||||||
"label-basic": 2, // 라벨 (16.67%)
|
"label-basic": 2 / 12, // 라벨 (16.67%)
|
||||||
"text-display": 3, // 텍스트 표시 (25%)
|
"text-display": 3 / 12, // 텍스트 표시 (25%)
|
||||||
"card-display": 8, // 카드 (66.67%)
|
"card-display": 8 / 12, // 카드 (66.67%)
|
||||||
"badge-basic": 1, // 배지 (8.33%)
|
"badge-basic": 1 / 12, // 배지 (8.33%)
|
||||||
"alert-basic": 6, // 알림 (50%)
|
"alert-basic": 6 / 12, // 알림 (50%)
|
||||||
"divider-basic": 12, // 구분선 (100%)
|
"divider-basic": 1, // 구분선 (100%)
|
||||||
"divider-line": 12, // 구분선 (100%)
|
"divider-line": 1, // 구분선 (100%)
|
||||||
"accordion-basic": 12, // 아코디언 (100%)
|
"accordion-basic": 1, // 아코디언 (100%)
|
||||||
"table-list": 12, // 테이블 리스트 (100%)
|
"table-list": 1, // 테이블 리스트 (100%)
|
||||||
"image-display": 4, // 이미지 표시 (33%)
|
"image-display": 4 / 12, // 이미지 표시 (33%)
|
||||||
"split-panel-layout": 6, // 분할 패널 레이아웃 (50%)
|
"split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%)
|
||||||
"flow-widget": 12, // 플로우 위젯 (100%)
|
"flow-widget": 1, // 플로우 위젯 (100%)
|
||||||
|
|
||||||
// 액션 컴포넌트 (ACTION 카테고리)
|
// 액션 컴포넌트 (ACTION 카테고리)
|
||||||
"button-basic": 1, // 버튼 (8.33%)
|
"button-basic": 1 / 12, // 버튼 (8.33%)
|
||||||
"button-primary": 1, // 프라이머리 버튼 (8.33%)
|
"button-primary": 1 / 12, // 프라이머리 버튼 (8.33%)
|
||||||
"button-secondary": 1, // 세컨더리 버튼 (8.33%)
|
"button-secondary": 1 / 12, // 세컨더리 버튼 (8.33%)
|
||||||
"icon-button": 1, // 아이콘 버튼 (8.33%)
|
"icon-button": 1 / 12, // 아이콘 버튼 (8.33%)
|
||||||
|
|
||||||
// 레이아웃 컴포넌트
|
// 레이아웃 컴포넌트
|
||||||
"container-basic": 6, // 컨테이너 (50%)
|
"container-basic": 6 / 12, // 컨테이너 (50%)
|
||||||
"section-basic": 12, // 섹션 (100%)
|
"section-basic": 1, // 섹션 (100%)
|
||||||
"panel-basic": 6, // 패널 (50%)
|
"panel-basic": 6 / 12, // 패널 (50%)
|
||||||
|
|
||||||
// 기타
|
// 기타
|
||||||
"image-basic": 4, // 이미지 (33%)
|
"image-basic": 4 / 12, // 이미지 (33%)
|
||||||
"icon-basic": 1, // 아이콘 (8.33%)
|
"icon-basic": 1 / 12, // 아이콘 (8.33%)
|
||||||
"progress-bar": 4, // 프로그레스 바 (33%)
|
"progress-bar": 4 / 12, // 프로그레스 바 (33%)
|
||||||
"chart-basic": 6, // 차트 (50%)
|
"chart-basic": 6 / 12, // 차트 (50%)
|
||||||
};
|
};
|
||||||
|
|
||||||
// defaultSize에 gridColumnSpan이 "full"이면 12컬럼 사용
|
// defaultSize에 gridColumnSpan이 "full"이면 전체 컬럼 사용
|
||||||
if (component.defaultSize?.gridColumnSpan === "full") {
|
if (component.defaultSize?.gridColumnSpan === "full") {
|
||||||
gridColumns = 12;
|
gridColumns = currentGridColumns;
|
||||||
} else {
|
} else {
|
||||||
// componentId 또는 webType으로 매핑, 없으면 기본값 3
|
// componentId 또는 webType으로 비율 찾기, 없으면 기본값 25%
|
||||||
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
|
const ratio = gridColumnsRatioMap[componentId] || gridColumnsRatioMap[webType] || 0.25;
|
||||||
|
// 현재 격자 컬럼 수에 비율을 곱하여 계산 (최소 1, 최대 currentGridColumns)
|
||||||
|
gridColumns = Math.max(1, Math.min(currentGridColumns, Math.round(ratio * currentGridColumns)));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🎯 컴포넌트 타입별 gridColumns 설정:", {
|
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 = {
|
const newComponent: ComponentData = {
|
||||||
id: generateComponentId(),
|
id: generateComponentId(),
|
||||||
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
||||||
|
|
@ -2162,6 +2177,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
labelColor: "#212121",
|
labelColor: "#212121",
|
||||||
labelFontWeight: "500",
|
labelFontWeight: "500",
|
||||||
labelMarginBottom: "4px",
|
labelMarginBottom: "4px",
|
||||||
|
width: `${widthPercent}%`, // gridColumns에 맞춘 퍼센트 너비
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -4238,7 +4254,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
<UnifiedPropertiesPanel
|
<UnifiedPropertiesPanel
|
||||||
selectedComponent={selectedComponent || undefined}
|
selectedComponent={selectedComponent || undefined}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
|
gridSettings={layout.gridSettings}
|
||||||
onUpdateProperty={updateComponentProperty}
|
onUpdateProperty={updateComponentProperty}
|
||||||
|
onGridSettingsChange={(newSettings) => {
|
||||||
|
setLayout((prev) => ({
|
||||||
|
...prev,
|
||||||
|
gridSettings: newSettings,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
onDeleteComponent={deleteComponent}
|
onDeleteComponent={deleteComponent}
|
||||||
onCopyComponent={copyComponent}
|
onCopyComponent={copyComponent}
|
||||||
currentTable={tables.length > 0 ? tables[0] : undefined}
|
currentTable={tables.length > 0 ? tables[0] : undefined}
|
||||||
|
|
|
||||||
|
|
@ -127,10 +127,27 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="columns" className="text-xs font-medium">
|
<Label htmlFor="columns" className="text-xs font-medium">
|
||||||
컬럼 수: <span className="text-primary">{gridSettings.columns}</span>
|
컬럼 수
|
||||||
</Label>
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="columns"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={24}
|
||||||
|
value={gridSettings.columns}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(value) && value >= 1 && value <= 24) {
|
||||||
|
updateSetting("columns", value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs">/ 24</span>
|
||||||
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
id="columns"
|
id="columns-slider"
|
||||||
min={1}
|
min={1}
|
||||||
max={24}
|
max={24}
|
||||||
step={1}
|
step={1}
|
||||||
|
|
@ -139,8 +156,8 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<div className="text-muted-foreground flex justify-between text-xs">
|
<div className="text-muted-foreground flex justify-between text-xs">
|
||||||
<span>1</span>
|
<span>1열</span>
|
||||||
<span>24</span>
|
<span>24열</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,12 @@ interface PropertiesPanelProps {
|
||||||
draggedComponent: ComponentData | null;
|
draggedComponent: ComponentData | null;
|
||||||
currentPosition: { x: number; y: number; z: number };
|
currentPosition: { x: number; y: number; z: number };
|
||||||
};
|
};
|
||||||
|
gridSettings?: {
|
||||||
|
columns: number;
|
||||||
|
gap: number;
|
||||||
|
padding: number;
|
||||||
|
snapToGrid: boolean;
|
||||||
|
};
|
||||||
onUpdateProperty: (path: string, value: unknown) => void;
|
onUpdateProperty: (path: string, value: unknown) => void;
|
||||||
onDeleteComponent: () => void;
|
onDeleteComponent: () => void;
|
||||||
onCopyComponent: () => void;
|
onCopyComponent: () => void;
|
||||||
|
|
@ -124,6 +130,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
selectedComponent,
|
selectedComponent,
|
||||||
tables = [],
|
tables = [],
|
||||||
dragState,
|
dragState,
|
||||||
|
gridSettings,
|
||||||
onUpdateProperty,
|
onUpdateProperty,
|
||||||
onDeleteComponent,
|
onDeleteComponent,
|
||||||
onCopyComponent,
|
onCopyComponent,
|
||||||
|
|
@ -744,9 +751,47 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
|
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
|
||||||
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
|
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
|
||||||
<>
|
<>
|
||||||
{/* 🆕 컬럼 스팬 선택 (width를 퍼센트로 변환) - 기존 UI 유지 */}
|
{/* 🆕 그리드 컬럼 수 직접 입력 */}
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<Label className="text-sm font-medium">컴포넌트 너비</Label>
|
<Label htmlFor="gridColumns" className="text-sm font-medium">
|
||||||
|
차지 컬럼 수
|
||||||
|
</Label>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="gridColumns"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={gridSettings?.columns || 12}
|
||||||
|
value={(selectedComponent as any)?.gridColumns || 1}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
/ {gridSettings?.columns || 12}열
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
이 컴포넌트가 차지할 그리드 컬럼 수 (1-{gridSettings?.columns || 12})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기존 컬럼 스팬 선택 (width를 퍼센트로 변환) - 참고용 */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label className="text-sm font-medium">미리 정의된 너비</Label>
|
||||||
<Select
|
<Select
|
||||||
value={localWidthSpan}
|
value={localWidthSpan}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,10 @@ import {
|
||||||
import { ColumnSpanPreset, COLUMN_SPAN_PRESETS } from "@/lib/constants/columnSpans";
|
import { ColumnSpanPreset, COLUMN_SPAN_PRESETS } from "@/lib/constants/columnSpans";
|
||||||
|
|
||||||
// 컬럼 스팬 숫자 배열 (1~12)
|
// 컬럼 스팬 숫자 배열 (1~12)
|
||||||
const COLUMN_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
// 동적으로 컬럼 수 배열 생성 (gridSettings.columns 기반)
|
||||||
|
const generateColumnNumbers = (maxColumns: number) => {
|
||||||
|
return Array.from({ length: maxColumns }, (_, i) => i + 1);
|
||||||
|
};
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import DataTableConfigPanel from "./DataTableConfigPanel";
|
import DataTableConfigPanel from "./DataTableConfigPanel";
|
||||||
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
|
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
|
||||||
|
|
@ -52,11 +55,23 @@ import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
||||||
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||||
import StyleEditor from "../StyleEditor";
|
import StyleEditor from "../StyleEditor";
|
||||||
import ResolutionPanel from "./ResolutionPanel";
|
import ResolutionPanel from "./ResolutionPanel";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Grid3X3, Eye, EyeOff, Zap } from "lucide-react";
|
||||||
|
|
||||||
interface UnifiedPropertiesPanelProps {
|
interface UnifiedPropertiesPanelProps {
|
||||||
selectedComponent?: ComponentData;
|
selectedComponent?: ComponentData;
|
||||||
tables: TableInfo[];
|
tables: TableInfo[];
|
||||||
|
gridSettings?: {
|
||||||
|
columns: number;
|
||||||
|
gap: number;
|
||||||
|
padding: number;
|
||||||
|
snapToGrid: boolean;
|
||||||
|
showGrid: boolean;
|
||||||
|
gridColor?: string;
|
||||||
|
gridOpacity?: number;
|
||||||
|
};
|
||||||
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
||||||
|
onGridSettingsChange?: (settings: any) => void;
|
||||||
onDeleteComponent?: (componentId: string) => void;
|
onDeleteComponent?: (componentId: string) => void;
|
||||||
onCopyComponent?: (componentId: string) => void;
|
onCopyComponent?: (componentId: string) => void;
|
||||||
currentTable?: TableInfo;
|
currentTable?: TableInfo;
|
||||||
|
|
@ -74,7 +89,9 @@ interface UnifiedPropertiesPanelProps {
|
||||||
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
selectedComponent,
|
selectedComponent,
|
||||||
tables,
|
tables,
|
||||||
|
gridSettings,
|
||||||
onUpdateProperty,
|
onUpdateProperty,
|
||||||
|
onGridSettingsChange,
|
||||||
onDeleteComponent,
|
onDeleteComponent,
|
||||||
onCopyComponent,
|
onCopyComponent,
|
||||||
currentTable,
|
currentTable,
|
||||||
|
|
@ -98,23 +115,154 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
}
|
}
|
||||||
}, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]);
|
}, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]);
|
||||||
|
|
||||||
// 컴포넌트가 선택되지 않았을 때도 해상도 설정은 표시
|
// 격자 설정 업데이트 함수 (early return 이전에 정의)
|
||||||
|
const updateGridSetting = (key: string, value: any) => {
|
||||||
|
if (onGridSettingsChange && gridSettings) {
|
||||||
|
onGridSettingsChange({
|
||||||
|
...gridSettings,
|
||||||
|
[key]: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 격자 설정 렌더링 (early return 이전에 정의)
|
||||||
|
const renderGridSettings = () => {
|
||||||
|
if (!gridSettings || !onGridSettingsChange) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Grid3X3 className="text-primary h-3 w-3" />
|
||||||
|
<h4 className="text-xs font-semibold">격자 설정</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 토글들 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{gridSettings.showGrid ? (
|
||||||
|
<Eye className="text-primary h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="text-muted-foreground h-3 w-3" />
|
||||||
|
)}
|
||||||
|
<Label htmlFor="showGrid" className="text-xs font-medium">
|
||||||
|
격자 표시
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
id="showGrid"
|
||||||
|
checked={gridSettings.showGrid}
|
||||||
|
onCheckedChange={(checked) => updateGridSetting("showGrid", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="text-primary h-3 w-3" />
|
||||||
|
<Label htmlFor="snapToGrid" className="text-xs font-medium">
|
||||||
|
격자 스냅
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
id="snapToGrid"
|
||||||
|
checked={gridSettings.snapToGrid}
|
||||||
|
onCheckedChange={(checked) => updateGridSetting("snapToGrid", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 수 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="columns" className="text-xs font-medium">
|
||||||
|
컬럼 수
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="columns"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={24}
|
||||||
|
value={gridSettings.columns}
|
||||||
|
onChange={(e) => {
|
||||||
|
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" }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-[10px] whitespace-nowrap">/ 24</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={1}
|
||||||
|
max={24}
|
||||||
|
step={1}
|
||||||
|
value={[gridSettings.columns]}
|
||||||
|
onValueChange={([value]) => updateGridSetting("columns", value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 간격 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="gap" className="text-xs font-medium">
|
||||||
|
간격: <span className="text-primary">{gridSettings.gap}px</span>
|
||||||
|
</Label>
|
||||||
|
<Slider
|
||||||
|
id="gap"
|
||||||
|
min={0}
|
||||||
|
max={40}
|
||||||
|
step={2}
|
||||||
|
value={[gridSettings.gap]}
|
||||||
|
onValueChange={([value]) => updateGridSetting("gap", value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 여백 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="padding" className="text-xs font-medium">
|
||||||
|
여백: <span className="text-primary">{gridSettings.padding}px</span>
|
||||||
|
</Label>
|
||||||
|
<Slider
|
||||||
|
id="padding"
|
||||||
|
min={0}
|
||||||
|
max={60}
|
||||||
|
step={4}
|
||||||
|
value={[gridSettings.padding]}
|
||||||
|
onValueChange={([value]) => updateGridSetting("padding", value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
|
||||||
if (!selectedComponent) {
|
if (!selectedComponent) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-white">
|
<div className="flex h-full flex-col bg-white">
|
||||||
{/* 해상도 설정만 표시 */}
|
{/* 해상도 설정과 격자 설정 표시 */}
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
<div className="space-y-4 text-xs">
|
<div className="space-y-4 text-xs">
|
||||||
|
{/* 해상도 설정 */}
|
||||||
{currentResolution && onResolutionChange && (
|
{currentResolution && onResolutionChange && (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="space-y-2">
|
||||||
<Monitor className="text-primary h-3 w-3" />
|
<div className="flex items-center gap-1.5">
|
||||||
<h4 className="text-xs font-semibold">해상도 설정</h4>
|
<Monitor className="text-primary h-3 w-3" />
|
||||||
|
<h4 className="text-xs font-semibold">해상도 설정</h4>
|
||||||
|
</div>
|
||||||
|
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
|
||||||
</div>
|
</div>
|
||||||
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
|
<Separator className="my-2" />
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 격자 설정 */}
|
||||||
|
{renderGridSettings()}
|
||||||
|
|
||||||
{/* 안내 메시지 */}
|
{/* 안내 메시지 */}
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
|
@ -283,22 +431,31 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{(selectedComponent as any).gridColumns !== undefined && (
|
{(selectedComponent as any).gridColumns !== undefined && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Grid</Label>
|
<Label className="text-xs">차지 컬럼 수</Label>
|
||||||
<Select
|
<div className="flex items-center gap-1">
|
||||||
value={((selectedComponent as any).gridColumns || 12).toString()}
|
<Input
|
||||||
onValueChange={(value) => handleUpdate("gridColumns", parseInt(value))}
|
type="number"
|
||||||
>
|
min={1}
|
||||||
<SelectTrigger className="h-6 w-full px-2 py-0" style={{ fontSize: "12px" }}>
|
max={gridSettings?.columns || 12}
|
||||||
<SelectValue />
|
value={(selectedComponent as any).gridColumns || 1}
|
||||||
</SelectTrigger>
|
onChange={(e) => {
|
||||||
<SelectContent>
|
const value = parseInt(e.target.value, 10);
|
||||||
{COLUMN_NUMBERS.map((span) => (
|
const maxColumns = gridSettings?.columns || 12;
|
||||||
<SelectItem key={span} value={span.toString()} style={{ fontSize: "12px" }}>
|
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
|
||||||
{span}열
|
handleUpdate("gridColumns", value);
|
||||||
</SelectItem>
|
|
||||||
))}
|
// width를 퍼센트로 계산하여 업데이트
|
||||||
</SelectContent>
|
const widthPercent = (value / maxColumns) * 100;
|
||||||
</Select>
|
handleUpdate("style.width", `${widthPercent}%`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-6 w-full px-2 py-0 text-xs"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
|
||||||
|
/{gridSettings?.columns || 12}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -896,6 +1053,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 격자 설정 - 해상도 설정 아래 표시 */}
|
||||||
|
{renderGridSettings()}
|
||||||
|
{gridSettings && onGridSettingsChange && <Separator className="my-2" />}
|
||||||
|
|
||||||
{/* 기본 설정 */}
|
{/* 기본 설정 */}
|
||||||
{renderBasicTab()}
|
{renderBasicTab()}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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<TabsWidgetProps> = ({ 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<string>(defaultTab || tabs[0]?.id || "");
|
||||||
|
const [loadedScreens, setLoadedScreens] = useState<Record<string, any>>({});
|
||||||
|
const [loadingScreens, setLoadingScreens] = useState<Record<string, boolean>>({});
|
||||||
|
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 탭 변경 시 화면 로드
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-muted-foreground text-sm">화면을 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 발생
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||||
|
<FileQuestion className="h-12 w-12 text-destructive" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="mb-2 font-medium text-destructive">화면 로드 실패</p>
|
||||||
|
<p className="text-muted-foreground text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화면 ID가 없는 경우
|
||||||
|
if (!tab.screenId) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||||
|
<FileQuestion className="text-muted-foreground h-12 w-12" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground mb-2 text-sm">화면이 할당되지 않았습니다</p>
|
||||||
|
<p className="text-xs text-gray-400">상세설정에서 화면을 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화면 렌더링 - 원본 화면의 모든 컴포넌트를 그대로 렌더링
|
||||||
|
if (screenData && screenData.layout && screenData.layout.components) {
|
||||||
|
const components = screenData.layout.components;
|
||||||
|
const screenResolution = screenData.layout.screenResolution || { width: 1920, height: 1080 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white" style={{ width: `${screenResolution.width}px`, height: '100%' }}>
|
||||||
|
<div className="relative h-full">
|
||||||
|
{components.map((comp) => (
|
||||||
|
<InteractiveScreenViewerDynamic
|
||||||
|
key={comp.id}
|
||||||
|
component={comp}
|
||||||
|
allComponents={components}
|
||||||
|
screenInfo={{ id: tab.screenId }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||||
|
<FileQuestion className="text-muted-foreground h-12 w-12" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">화면 데이터를 불러올 수 없습니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 빈 탭 목록
|
||||||
|
if (tabs.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="flex h-full w-full items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">탭이 없습니다</p>
|
||||||
|
<p className="text-xs text-gray-400">상세설정에서 탭을 추가하세요</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full overflow-auto">
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
orientation={orientation}
|
||||||
|
className="flex h-full w-full flex-col"
|
||||||
|
>
|
||||||
|
<TabsList className={orientation === "horizontal" ? "justify-start shrink-0" : "flex-col shrink-0"}>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.id}
|
||||||
|
value={tab.id}
|
||||||
|
disabled={tab.disabled}
|
||||||
|
className={orientation === "horizontal" ? "" : "w-full justify-start"}
|
||||||
|
>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{tab.screenName && (
|
||||||
|
<Badge variant="secondary" className="ml-2 text-[10px]">
|
||||||
|
{tab.screenName}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabsContent
|
||||||
|
key={tab.id}
|
||||||
|
value={tab.id}
|
||||||
|
className="flex-1 mt-0 data-[state=inactive]:hidden"
|
||||||
|
>
|
||||||
|
{renderTabContent(tab)}
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -223,6 +223,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
// 카드 컴포넌트는 ...style 스프레드가 없으므로 여기서 명시적으로 설정
|
||||||
|
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
componentStyle.border = "1px dashed hsl(var(--border))";
|
componentStyle.border = "1px dashed hsl(var(--border))";
|
||||||
componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))";
|
componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))";
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,8 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
backgroundColor: "hsl(var(--background))",
|
backgroundColor: "hsl(var(--background))",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -1167,7 +1169,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
<div className="mt-10" style={{ flex: 1, overflow: "hidden" }}>
|
||||||
<SingleTableWithSticky
|
<SingleTableWithSticky
|
||||||
data={data}
|
data={data}
|
||||||
columns={visibleColumns}
|
columns={visibleColumns}
|
||||||
|
|
@ -1261,7 +1263,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 테이블 컨테이너 */}
|
{/* 테이블 컨테이너 */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden w-full max-w-full">
|
<div className="flex-1 flex flex-col overflow-hidden w-full max-w-full mt-10">
|
||||||
{/* 스크롤 영역 */}
|
{/* 스크롤 영역 */}
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-full h-[400px] overflow-y-scroll overflow-x-auto bg-background sm:h-[500px]"
|
className="w-full max-w-full h-[400px] overflow-y-scroll overflow-x-auto bg-background sm:h-[500px]"
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
// 숨김 기능: 편집 모드에서만 연하게 표시
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
|
// 숨김 기능: 편집 모드에서만 연하게 표시
|
||||||
...(isHidden &&
|
...(isHidden &&
|
||||||
isDesignMode && {
|
isDesignMode && {
|
||||||
opacity: 0.4,
|
opacity: 0.4,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 코드 파트 유형
|
* 코드 파트 유형 (4가지)
|
||||||
*/
|
*/
|
||||||
export type CodePartType =
|
export type CodePartType =
|
||||||
| "prefix" // 접두사 (고정 문자열)
|
| "sequence" // 순번 (자동 증가 숫자)
|
||||||
| "sequence" // 순번 (자동 증가)
|
| "number" // 숫자 (고정 자릿수)
|
||||||
| "date" // 날짜 (YYYYMMDD 등)
|
| "date" // 날짜 (다양한 날짜 형식)
|
||||||
| "year" // 연도 (YYYY)
|
| "text"; // 문자 (텍스트)
|
||||||
| "month" // 월 (MM)
|
|
||||||
| "custom"; // 사용자 정의
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 생성 방식
|
* 생성 방식
|
||||||
|
|
@ -43,11 +41,19 @@ export interface NumberingRulePart {
|
||||||
|
|
||||||
// 자동 생성 설정
|
// 자동 생성 설정
|
||||||
autoConfig?: {
|
autoConfig?: {
|
||||||
prefix?: string; // 접두사
|
// 순번용
|
||||||
sequenceLength?: number; // 순번 자릿수
|
sequenceLength?: number; // 순번 자릿수 (예: 3 → 001)
|
||||||
startFrom?: number; // 시작 번호
|
startFrom?: number; // 시작 번호 (기본: 1)
|
||||||
|
|
||||||
|
// 숫자용
|
||||||
|
numberLength?: number; // 숫자 자릿수 (예: 4 → 0001)
|
||||||
|
numberValue?: number; // 숫자 값
|
||||||
|
|
||||||
|
// 날짜용
|
||||||
dateFormat?: DateFormat; // 날짜 형식
|
dateFormat?: DateFormat; // 날짜 형식
|
||||||
value?: string; // 커스텀 값
|
|
||||||
|
// 문자용
|
||||||
|
textValue?: string; // 텍스트 값 (예: "PRJ", "CODE")
|
||||||
};
|
};
|
||||||
|
|
||||||
// 직접 입력 설정
|
// 직접 입력 설정
|
||||||
|
|
@ -74,6 +80,10 @@ export interface NumberingRuleConfig {
|
||||||
resetPeriod?: "none" | "daily" | "monthly" | "yearly";
|
resetPeriod?: "none" | "daily" | "monthly" | "yearly";
|
||||||
currentSequence?: number; // 현재 시퀀스
|
currentSequence?: number; // 현재 시퀀스
|
||||||
|
|
||||||
|
// 적용 범위
|
||||||
|
scopeType?: "global" | "menu"; // 적용 범위 (전역/메뉴별)
|
||||||
|
menuObjid?: number; // 적용할 메뉴 OBJID (상위 메뉴 기준)
|
||||||
|
|
||||||
// 적용 대상
|
// 적용 대상
|
||||||
tableName?: string; // 적용할 테이블명
|
tableName?: string; // 적용할 테이블명
|
||||||
columnName?: string; // 적용할 컬럼명
|
columnName?: string; // 적용할 컬럼명
|
||||||
|
|
@ -88,13 +98,11 @@ export interface NumberingRuleConfig {
|
||||||
/**
|
/**
|
||||||
* UI 옵션 상수
|
* UI 옵션 상수
|
||||||
*/
|
*/
|
||||||
export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string }> = [
|
export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string; description: string }> = [
|
||||||
{ value: "prefix", label: "접두사" },
|
{ value: "sequence", label: "순번", description: "자동 증가 순번 (1, 2, 3...)" },
|
||||||
{ value: "sequence", label: "순번" },
|
{ value: "number", label: "숫자", description: "고정 자릿수 숫자 (001, 002...)" },
|
||||||
{ value: "date", label: "날짜" },
|
{ value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" },
|
||||||
{ value: "year", label: "연도" },
|
{ value: "text", label: "문자", description: "텍스트 또는 코드" },
|
||||||
{ value: "month", label: "월" },
|
|
||||||
{ value: "custom", label: "사용자 정의" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [
|
export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="border rounded-lg p-4"> {/* 중첩 박스! */}
|
||||||
|
<div className="border rounded p-2"> {/* 또 중첩! */}
|
||||||
|
내용
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ 올바른 예시**:
|
||||||
|
```tsx
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>제목</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 직접 컨텐츠 배치 */}
|
||||||
|
<div>내용 1</div>
|
||||||
|
<div>내용 2</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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. 다음 개선 사항 (선택사항)
|
||||||
|
|
||||||
|
- [ ] 규칙 순서 드래그앤드롭으로 변경
|
||||||
|
- [ ] 규칙 복제 기능
|
||||||
|
- [ ] 규칙 템플릿 제공 (자주 사용하는 패턴)
|
||||||
|
- [ ] 코드 검증 로직
|
||||||
|
- [ ] 테이블 생성 시 자동 채번 컬럼 추가 통합
|
||||||
|
- [ ] 화면관리에서 입력 폼에 자동 코드 생성 버튼 추가
|
||||||
|
|
||||||
Loading…
Reference in New Issue