사용자 대시보드 기능강화 및 인비온 스튜디오 메뉴관리 자잘한수정
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m22s
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m22s
This commit is contained in:
@@ -550,15 +550,147 @@ public class EntityJoinService extends BaseService {
|
||||
if (searchConditions == null || searchConditions.isEmpty()) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map.Entry<String, Object> e : searchConditions.entrySet()) {
|
||||
if (e.getValue() == null) continue;
|
||||
if (isKeywordSearchKey(e.getKey())) {
|
||||
SearchCondition condition = normalizeSearchCondition(e.getValue());
|
||||
if (condition == null || isBlankValue(condition.value())) continue;
|
||||
appendKeywordSearchCondition(sb, params, tableName, condition);
|
||||
continue;
|
||||
}
|
||||
if (!IDENTIFIER_PATTERN.matcher(e.getKey()).matches()) continue;
|
||||
SearchCondition condition = normalizeSearchCondition(e.getValue());
|
||||
if (condition == null) continue;
|
||||
if (sb.length() > 0) sb.append(" AND ");
|
||||
sb.append("main.\"").append(e.getKey()).append("\" = ?");
|
||||
params.add(e.getValue());
|
||||
appendSearchCondition(sb, params, e.getKey(), condition);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private SearchCondition normalizeSearchCondition(Object rawValue) {
|
||||
if (rawValue == null) return null;
|
||||
|
||||
if (rawValue instanceof Map<?, ?> rawMap) {
|
||||
Map<String, Object> map = (Map<String, Object>) rawMap;
|
||||
String operator = String.valueOf(map.getOrDefault("operator", "contains"));
|
||||
Object value = map.get("value");
|
||||
|
||||
if (!"isEmpty".equalsIgnoreCase(operator)
|
||||
&& !"isNotEmpty".equalsIgnoreCase(operator)
|
||||
&& isBlankValue(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SearchCondition(operator, value);
|
||||
}
|
||||
|
||||
if (isBlankValue(rawValue)) return null;
|
||||
return new SearchCondition("equals", rawValue);
|
||||
}
|
||||
|
||||
private void appendSearchCondition(StringBuilder sb,
|
||||
List<Object> params,
|
||||
String columnName,
|
||||
SearchCondition condition) {
|
||||
String op = condition.operator();
|
||||
Object value = condition.value();
|
||||
String colExpr = "main.\"" + columnName + "\"";
|
||||
|
||||
switch (op.toLowerCase()) {
|
||||
case "contains" -> {
|
||||
sb.append(colExpr).append("::text ILIKE ?");
|
||||
params.add("%" + value + "%");
|
||||
}
|
||||
case "startswith" -> {
|
||||
sb.append(colExpr).append("::text ILIKE ?");
|
||||
params.add(value + "%");
|
||||
}
|
||||
case "endswith" -> {
|
||||
sb.append(colExpr).append("::text ILIKE ?");
|
||||
params.add("%" + value);
|
||||
}
|
||||
case "notequals" -> {
|
||||
sb.append("COALESCE(").append(colExpr).append("::text, '') <> ?");
|
||||
params.add(String.valueOf(value));
|
||||
}
|
||||
case "isempty" -> sb.append("(").append(colExpr).append(" IS NULL OR ")
|
||||
.append(colExpr).append("::text = '')");
|
||||
case "isnotempty" -> sb.append("(").append(colExpr).append(" IS NOT NULL AND ")
|
||||
.append(colExpr).append("::text <> '')");
|
||||
case "greaterthan" -> {
|
||||
sb.append(colExpr).append("::text > ?");
|
||||
params.add(String.valueOf(value));
|
||||
}
|
||||
case "lessthan" -> {
|
||||
sb.append(colExpr).append("::text < ?");
|
||||
params.add(String.valueOf(value));
|
||||
}
|
||||
case "equals", "=", "exact" -> {
|
||||
sb.append(colExpr).append("::text = ?");
|
||||
params.add(String.valueOf(value));
|
||||
}
|
||||
default -> {
|
||||
sb.append(colExpr).append("::text ILIKE ?");
|
||||
params.add("%" + value + "%");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void appendKeywordSearchCondition(StringBuilder sb,
|
||||
List<Object> params,
|
||||
String tableName,
|
||||
SearchCondition condition) {
|
||||
List<String> columns = getKeywordSearchColumns(tableName);
|
||||
if (columns.isEmpty()) return;
|
||||
|
||||
if (sb.length() > 0) sb.append(" AND ");
|
||||
sb.append("(");
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
if (i > 0) sb.append(" OR ");
|
||||
sb.append("main.\"").append(columns.get(i)).append("\"::text ILIKE ?");
|
||||
params.add("%" + String.valueOf(condition.value()) + "%");
|
||||
}
|
||||
sb.append(")");
|
||||
}
|
||||
|
||||
private List<String> getKeywordSearchColumns(String tableName) {
|
||||
validateIdentifier(tableName);
|
||||
|
||||
String sql = """
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = ?
|
||||
ORDER BY ordinal_position
|
||||
""";
|
||||
|
||||
List<String> allColumns = jdbcTemplate.queryForList(sql, String.class, tableName);
|
||||
|
||||
return allColumns.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(col -> IDENTIFIER_PATTERN.matcher(col).matches())
|
||||
.filter(col -> !Set.of(
|
||||
"company_code",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"create_user",
|
||||
"update_user"
|
||||
).contains(col))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private boolean isKeywordSearchKey(String key) {
|
||||
return "keyword".equalsIgnoreCase(key)
|
||||
|| "search".equalsIgnoreCase(key)
|
||||
|| "searchterm".equalsIgnoreCase(key)
|
||||
|| "q".equalsIgnoreCase(key);
|
||||
}
|
||||
|
||||
private boolean isBlankValue(Object value) {
|
||||
return value == null || value.toString().isBlank() || "__ALL__".equals(value.toString());
|
||||
}
|
||||
|
||||
private void validateIdentifier(String name) {
|
||||
if (name == null || !IDENTIFIER_PATTERN.matcher(name).matches()) {
|
||||
throw new IllegalArgumentException("유효하지 않은 식별자: " + name);
|
||||
@@ -586,4 +718,6 @@ public class EntityJoinService extends BaseService {
|
||||
return total == 0 ? 0.0 : (double) hits / total;
|
||||
}
|
||||
}
|
||||
|
||||
private record SearchCondition(String operator, Object value) {}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,9 @@ import { MenuScopePanel } from "@/components/admin/menu/MenuScopePanel";
|
||||
import { MenuTreePanel } from "@/components/admin/menu/MenuTreePanel";
|
||||
import { MenuOverviewPanel } from "@/components/admin/menu/MenuOverviewPanel";
|
||||
import { MenuSettingsPanel } from "@/components/admin/menu/MenuSettingsPanel";
|
||||
import { MenuActivityPanel } from "@/components/admin/menu/MenuActivityPanel";
|
||||
|
||||
type Scope = "admin" | "user";
|
||||
type View = "overview" | "settings" | "activity";
|
||||
type View = "overview" | "settings";
|
||||
|
||||
export default function MenuPage() {
|
||||
const { refreshMenus } = useMenu();
|
||||
@@ -412,15 +411,6 @@ export default function MenuPage() {
|
||||
</svg>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
className={`v5-mm-vt-btn${view === "activity" ? " on" : ""}`}
|
||||
onClick={() => setView("activity")}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
Activity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -445,7 +435,6 @@ export default function MenuPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{view === "activity" && <MenuActivityPanel />}
|
||||
</section>
|
||||
</div>
|
||||
</LoadingOverlay>
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Form Popup — 등록/수정 팝업 창 (window.open 으로 열리는 별도 브라우저 창).
|
||||
*
|
||||
* URL: /form-popup?templateId={id}&mode={create|edit}&key={uniqueKey}
|
||||
*
|
||||
* 초기 데이터는 부모 창(DashboardCard)이 localStorage 에 임시 저장하고,
|
||||
* 팝업이 mount 시 key 로 읽어 즉시 삭제한다. 저장 성공 시 부모 창에
|
||||
* postMessage({ type: 'form-popup-saved', key, mode }) 를 보내고 자동 닫힘.
|
||||
*
|
||||
* (main) 레이아웃 바깥이라 헤더/사이드바 없이 깔끔한 팝업 UI.
|
||||
*/
|
||||
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { X } from 'lucide-react';
|
||||
import { FcForm } from '@/components/fc';
|
||||
import { getTemplateInfo } from '@/lib/api/template';
|
||||
import { fcInsert, fcUpdate } from '@/lib/api/fcData';
|
||||
import {
|
||||
TemplateRenderer,
|
||||
type TemplateRenderContext,
|
||||
} from '@/components/dash/TemplateRenderer';
|
||||
import type { FieldConfig, Template } from '@/types/invyone-component';
|
||||
|
||||
export default function FormPopupPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6 text-sm">로딩 중...</div>}>
|
||||
<FormPopupContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function FormPopupContent() {
|
||||
const params = useSearchParams();
|
||||
const templateId = params.get('templateId') ?? '';
|
||||
const mode = (params.get('mode') ?? 'create') as 'create' | 'edit';
|
||||
const key = params.get('key') ?? '';
|
||||
|
||||
const [template, setTemplate] = useState<Template | null>(null);
|
||||
const [fields, setFields] = useState<FieldConfig[]>([]);
|
||||
const [primaryTable, setPrimaryTable] = useState('');
|
||||
const [templateName, setTemplateName] = useState('');
|
||||
const [formRow, setFormRow] = useState<Record<string, any> | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateId) {
|
||||
setError('templateId 가 없습니다');
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 에서 부모가 넘겨준 초기 데이터 읽기 + 즉시 삭제 (1회성)
|
||||
let seededName = '';
|
||||
let seededTable = '';
|
||||
try {
|
||||
const raw = localStorage.getItem(`form-popup:${key}`);
|
||||
if (raw) {
|
||||
const data = JSON.parse(raw);
|
||||
setFormRow(data.initialRow ?? {});
|
||||
if (data.primaryTable) {
|
||||
seededTable = String(data.primaryTable);
|
||||
setPrimaryTable(seededTable);
|
||||
}
|
||||
if (data.templateName) {
|
||||
seededName = String(data.templateName);
|
||||
setTemplateName(seededName);
|
||||
}
|
||||
localStorage.removeItem(`form-popup:${key}`);
|
||||
} else {
|
||||
setFormRow({});
|
||||
}
|
||||
} catch {
|
||||
setFormRow({});
|
||||
}
|
||||
|
||||
getTemplateInfo(templateId)
|
||||
.then((tpl) => {
|
||||
if (tpl) {
|
||||
setTemplate(tpl as Template);
|
||||
if (Array.isArray((tpl as any).fields)) {
|
||||
setFields((tpl as any).fields as FieldConfig[]);
|
||||
}
|
||||
if (!seededName) setTemplateName((tpl as any).name ?? '');
|
||||
if (!seededTable) {
|
||||
const pt =
|
||||
(tpl as any).primary_table ?? (tpl as any).PRIMARY_TABLE ?? '';
|
||||
setPrimaryTable(pt);
|
||||
}
|
||||
}
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err?.message ?? '템플릿 로드 실패');
|
||||
setLoaded(true);
|
||||
});
|
||||
}, [templateId, key]);
|
||||
|
||||
const pkColumn = useMemo(() => {
|
||||
const pkField = fields.find((f) => f.pk);
|
||||
return pkField?.column ?? '';
|
||||
}, [fields]);
|
||||
|
||||
const handleFormRowChange = useCallback((patch: Record<string, any>) => {
|
||||
setFormRow((prev) => ({ ...(prev ?? {}), ...patch }));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (rowOverride?: Record<string, any>) => {
|
||||
const row = rowOverride ?? formRow;
|
||||
if (!row || !primaryTable) return;
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
await fcInsert(primaryTable, row);
|
||||
} else {
|
||||
if (!pkColumn) throw new Error('PK 컬럼이 없습니다');
|
||||
const id = String(row[pkColumn] ?? '');
|
||||
if (!id) throw new Error('PK 값이 비어있습니다');
|
||||
await fcUpdate(primaryTable, id, row);
|
||||
}
|
||||
try {
|
||||
window.opener?.postMessage(
|
||||
{ type: 'form-popup-saved', key, mode },
|
||||
window.location.origin,
|
||||
);
|
||||
} catch {
|
||||
/* opener 접근 실패해도 팝업은 닫음 */
|
||||
}
|
||||
window.close();
|
||||
} catch (err: any) {
|
||||
console.error('[FormPopup] 저장 실패:', err);
|
||||
toast.error(err?.message ?? '저장 실패');
|
||||
}
|
||||
},
|
||||
[formRow, primaryTable, pkColumn, mode, key],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
window.close();
|
||||
}, []);
|
||||
|
||||
const hasCustomView = useMemo(() => {
|
||||
const view = (template?.views as any)?.[mode];
|
||||
if (!view) return false;
|
||||
if (Array.isArray(view)) return view.length > 0;
|
||||
if (Array.isArray(view.blocks)) return view.blocks.length > 0;
|
||||
if (Array.isArray(view.components)) return view.components.length > 0;
|
||||
return false;
|
||||
}, [template, mode]);
|
||||
|
||||
const context: TemplateRenderContext = useMemo(
|
||||
() => ({
|
||||
fields,
|
||||
data: [],
|
||||
loading: false,
|
||||
primaryTable,
|
||||
selectedRow: null,
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
searchParams: {},
|
||||
onSearch: () => {},
|
||||
onRowSelect: () => {},
|
||||
onPageChange: () => {},
|
||||
onAdd: () => {},
|
||||
onEdit: () => {},
|
||||
onDelete: () => {},
|
||||
formRow: formRow ?? undefined,
|
||||
onFormRowChange: handleFormRowChange,
|
||||
onFormSubmit: () => handleSubmit(),
|
||||
onFormCancel: handleCancel,
|
||||
}),
|
||||
[
|
||||
fields,
|
||||
primaryTable,
|
||||
formRow,
|
||||
handleFormRowChange,
|
||||
handleSubmit,
|
||||
handleCancel,
|
||||
],
|
||||
);
|
||||
|
||||
if (!loaded) return <div className="p-6 text-sm">로딩 중...</div>;
|
||||
if (error)
|
||||
return (
|
||||
<div className="p-6 text-sm text-destructive">⚠ {error}</div>
|
||||
);
|
||||
if (!template)
|
||||
return (
|
||||
<div className="p-6 text-sm text-destructive">
|
||||
템플릿을 찾을 수 없습니다
|
||||
</div>
|
||||
);
|
||||
|
||||
const title = `${templateName || '템플릿'} ${mode === 'create' ? '등록' : '수정'}`;
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-screen flex-col">
|
||||
<div className="border-border flex items-center justify-between border-b px-4 py-2">
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="hover:bg-muted flex h-7 w-7 items-center justify-center rounded"
|
||||
title="닫기 (Esc)"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{hasCustomView ? (
|
||||
<TemplateRenderer
|
||||
template={template}
|
||||
context={context}
|
||||
view={mode}
|
||||
/>
|
||||
) : (
|
||||
<FcForm
|
||||
fields={fields}
|
||||
loadRow={formRow ?? {}}
|
||||
onSubmit={(row) => handleSubmit(row)}
|
||||
config={{ columns: 2 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+56
-29
@@ -130,43 +130,45 @@
|
||||
--sidebar-ring: 245 75% 57%;
|
||||
}
|
||||
|
||||
/* ===== Dark Theme (Deep Space) ===== */
|
||||
/* ===== Dark Theme (Neutral Dark) =====
|
||||
2026-04-22: hue 240 → 220 으로 이동, 채도 확 낮춰서 중성 다크로.
|
||||
primary/accent(보라·시안·핑크) 는 v5 정체성 유지. 배경/서피스만 무채색화. */
|
||||
.dark {
|
||||
/* 배경: 딥 스페이스 — 보라빛 암흑 */
|
||||
--background: 240 20% 3.5%;
|
||||
--foreground: 240 10% 95%;
|
||||
/* 배경: 중성 다크 */
|
||||
--background: 220 6% 5%;
|
||||
--foreground: 220 6% 95%;
|
||||
|
||||
/* 카드: 성운 표면 — 배경보다 약간 밝은 우주 공간 */
|
||||
--card: 240 18% 7%;
|
||||
--card-foreground: 240 10% 95%;
|
||||
/* 카드: 배경보다 약간 밝은 중성 다크 */
|
||||
--card: 220 6% 9%;
|
||||
--card-foreground: 220 6% 95%;
|
||||
|
||||
/* 팝오버: 카드와 동일한 우주 공간 */
|
||||
--popover: 240 18% 7%;
|
||||
--popover-foreground: 240 10% 95%;
|
||||
/* 팝오버: 카드와 동일 */
|
||||
--popover: 220 6% 9%;
|
||||
--popover-foreground: 220 6% 95%;
|
||||
|
||||
/* Primary: 코스믹 바이올렛-블루 — 성운 핵심 컬러 */
|
||||
/* Primary: 코스믹 바이올렛-블루 — v5 정체성 유지 */
|
||||
--primary: 245 85% 68%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Secondary: 우주 먼지 — 깊은 슬레이트 */
|
||||
--secondary: 240 15% 14%;
|
||||
--secondary-foreground: 240 10% 90%;
|
||||
/* Secondary: 중성 슬레이트 */
|
||||
--secondary: 220 5% 15%;
|
||||
--secondary-foreground: 220 6% 90%;
|
||||
|
||||
/* Muted: 암흑 성운 — 차분한 보라빛 회색 */
|
||||
--muted: 240 12% 12%;
|
||||
--muted-foreground: 240 8% 58%;
|
||||
/* Muted: 차분한 중성 회색 */
|
||||
--muted: 220 5% 13%;
|
||||
--muted-foreground: 220 5% 58%;
|
||||
|
||||
/* Accent: 은하 표면 — secondary보다 약간 밝게 */
|
||||
--accent: 240 15% 18%;
|
||||
--accent-foreground: 240 10% 90%;
|
||||
/* Accent: secondary 보다 약간 밝게 */
|
||||
--accent: 220 5% 18%;
|
||||
--accent-foreground: 220 6% 90%;
|
||||
|
||||
/* Destructive: 초신성 레드 */
|
||||
--destructive: 0 75% 55%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Border: 스타더스트 라인 */
|
||||
--border: 240 12% 15%;
|
||||
--input: 240 12% 15%;
|
||||
/* Border: 중성 라인 */
|
||||
--border: 220 5% 16%;
|
||||
--input: 220 5% 16%;
|
||||
--ring: 245 85% 68%;
|
||||
|
||||
/* Success: 오로라 그린 */
|
||||
@@ -188,14 +190,14 @@
|
||||
--chart-4: 38 90% 62%;
|
||||
--chart-5: 340 78% 62%;
|
||||
|
||||
/* Sidebar: 이벤트 호라이즌 — 메인 배경보다 더 깊은 우주 */
|
||||
--sidebar-background: 240 22% 5%;
|
||||
--sidebar-foreground: 240 10% 90%;
|
||||
/* Sidebar: 메인 배경보다 살짝 더 깊은 중성 다크 */
|
||||
--sidebar-background: 220 6% 7%;
|
||||
--sidebar-foreground: 220 6% 90%;
|
||||
--sidebar-primary: 245 85% 68%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 15% 14%;
|
||||
--sidebar-accent-foreground: 240 10% 90%;
|
||||
--sidebar-border: 240 12% 13%;
|
||||
--sidebar-accent: 220 5% 15%;
|
||||
--sidebar-accent-foreground: 220 6% 90%;
|
||||
--sidebar-border: 220 5% 14%;
|
||||
--sidebar-ring: 245 85% 68%;
|
||||
}
|
||||
|
||||
@@ -209,6 +211,31 @@ body {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
/* ===== 다크모드 테마 컬러 워시 (2026-04-22) =====
|
||||
중성 다크 배경 위에 primary 컬러를 3개의 거대 라디얼로 부드럽게 깔아
|
||||
"전체에 은은하게 테마 컬러가 감도는" 느낌을 냄.
|
||||
--v5-primary-rgb 참조 → data-color 프리셋(purple/blue/green/orange/pink/cyan)
|
||||
바꾸면 자동으로 그 색으로 전환.
|
||||
background-attachment: fixed 로 스크롤해도 viewport 기준 유지.
|
||||
로그인은 .v5-cosmos 가 덮어서 자연 가려짐. */
|
||||
.dark body {
|
||||
background:
|
||||
/* 좌상단 스팟 */
|
||||
radial-gradient(ellipse 1600px 1100px at 15% 10%,
|
||||
rgba(var(--v5-primary-rgb), 0.05) 0%,
|
||||
transparent 55%),
|
||||
/* 우하단 스팟 */
|
||||
radial-gradient(ellipse 1400px 1000px at 85% 95%,
|
||||
rgba(var(--v5-primary-rgb), 0.035) 0%,
|
||||
transparent 55%),
|
||||
/* 중앙 얕은 wash — 가장자리 어두워짐 방지 */
|
||||
radial-gradient(ellipse 1800px 1200px at 50% 50%,
|
||||
rgba(var(--v5-primary-rgb), 0.018) 0%,
|
||||
transparent 70%),
|
||||
hsl(var(--background));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Button 기본 커서 스타일 */
|
||||
button {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronDown, Search } from "lucide-react";
|
||||
import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang";
|
||||
import { MENU_MANAGEMENT_KEYS, getMenuTextSync } from "@/lib/utils/multilang";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { MenuIconPicker } from "./MenuIconPicker";
|
||||
|
||||
@@ -62,8 +62,9 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||
// });
|
||||
|
||||
// 다국어 텍스트 가져오기 함수
|
||||
// 우선순위: 부모가 내려준 uiTexts > multilang defaultTexts(한국어) > fallback > key
|
||||
const getText = (key: string, fallback?: string): string => {
|
||||
return uiTexts[key] || fallback || key;
|
||||
return uiTexts[key] || getMenuTextSync(key, "KR") || fallback || key;
|
||||
};
|
||||
|
||||
// console.log("🔍 MenuFormModal 컴포넌트 마운트됨");
|
||||
|
||||
@@ -359,7 +359,8 @@ const KOREAN_KEYWORDS: Record<string, string[]> = {
|
||||
};
|
||||
|
||||
interface IconEntry {
|
||||
name: string;
|
||||
name: string; // 원본 Lucide 이름 (저장/검색 키)
|
||||
displayName: string; // UI 표시용 — KOREAN_KEYWORDS 첫 단어, 없으면 name
|
||||
component: IconComponent;
|
||||
keywords: string[];
|
||||
}
|
||||
@@ -380,11 +381,18 @@ const ALL_ICONS: IconEntry[] = (() => {
|
||||
const koreanKw = KOREAN_KEYWORDS[name] || [];
|
||||
entries.push({
|
||||
name,
|
||||
displayName: koreanKw[0] || name,
|
||||
component: comp as IconComponent,
|
||||
keywords: [...koreanKw, name.toLowerCase()],
|
||||
});
|
||||
}
|
||||
return entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
// 한글 표시명 기준으로 정렬하되, 한글 매핑이 없어 영문 name 인 항목은 뒤로
|
||||
return entries.sort((a, b) => {
|
||||
const aKo = a.displayName !== a.name;
|
||||
const bKo = b.displayName !== b.name;
|
||||
if (aKo !== bKo) return aKo ? -1 : 1;
|
||||
return a.displayName.localeCompare(b.displayName, "ko");
|
||||
});
|
||||
})();
|
||||
|
||||
export function getIconComponent(iconName: string | null | undefined): IconComponent | null {
|
||||
@@ -456,20 +464,20 @@ export const MenuIconPicker: React.FC<MenuIconPickerProps> = ({
|
||||
const SelectedIconComponent = selectedIcon?.component;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{label}</Label>
|
||||
<div className="menu-icon-picker space-y-2">
|
||||
{label ? <Label>{label}</Label> : null}
|
||||
<div className="relative" ref={containerRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="h-10 w-full justify-between text-sm"
|
||||
className="menu-icon-picker-trigger h-10 w-full justify-between text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{SelectedIconComponent ? (
|
||||
<>
|
||||
<SelectedIconComponent className="h-4 w-4" />
|
||||
<span>{selectedIcon?.name}</span>
|
||||
<span>{selectedIcon?.displayName ?? selectedIcon?.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">아이콘을 선택하세요 (선택사항)</span>
|
||||
@@ -522,19 +530,19 @@ export const MenuIconPicker: React.FC<MenuIconPickerProps> = ({
|
||||
<button
|
||||
key={entry.name}
|
||||
type="button"
|
||||
title={entry.name}
|
||||
title={`${entry.displayName} (${entry.name})`}
|
||||
onClick={() => {
|
||||
onChange(entry.name);
|
||||
setIsOpen(false);
|
||||
setSearchText("");
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-md p-2 transition-colors hover:bg-accent",
|
||||
"menu-icon-picker-option flex min-h-[68px] flex-col items-center justify-center rounded-md p-2 transition-colors hover:bg-accent",
|
||||
value === entry.name && "bg-primary/10 ring-primary ring-1"
|
||||
)}
|
||||
>
|
||||
<IconComp className="h-5 w-5" />
|
||||
<span className="mt-1 max-w-full truncate text-[9px] leading-tight">{entry.name}</span>
|
||||
<IconComp className="h-5 w-5 shrink-0" />
|
||||
<span className="mt-1 max-w-full truncate text-[9px] leading-tight">{entry.displayName}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { menuApi } from "@/lib/api/menu";
|
||||
import type { MenuItem, MenuFormData } from "@/lib/api/menu";
|
||||
import type { MenuItem, MenuFormData, LangKey } from "@/lib/api/menu";
|
||||
import { companyAPI } from "@/lib/api/company";
|
||||
import { MenuIconPicker } from "@/components/admin/MenuIconPicker";
|
||||
|
||||
interface Props {
|
||||
menu: MenuItem;
|
||||
@@ -13,6 +15,12 @@ interface Props {
|
||||
onOpenAdvanced: (menuId: string) => void;
|
||||
}
|
||||
|
||||
interface CompanyOption {
|
||||
company_code: string;
|
||||
company_name: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
menuNameKor: string;
|
||||
menuUrl: string;
|
||||
@@ -42,10 +50,50 @@ export function MenuSettingsPanel({ menu, parentName, onSaved, onDelete, onOpenA
|
||||
const [form, setForm] = useState<FormState>(initial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 드롭다운 옵션 소스
|
||||
const [companies, setCompanies] = useState<CompanyOption[]>([]);
|
||||
const [langKeys, setLangKeys] = useState<LangKey[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setForm(toForm(menu));
|
||||
}, [menu]);
|
||||
|
||||
// 회사 목록 로드 (1회)
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const list = await companyAPI.getList({ status: "active" });
|
||||
if (alive) setCompanies(list || []);
|
||||
} catch {
|
||||
/* silent - 기본값 사용 */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 다국어 키 로드 (companyCode 변경 시 재로드, 활성 키만 필터)
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await menuApi.getLangKeys({
|
||||
companyCode: form.companyCode || "*",
|
||||
});
|
||||
if (alive && resp.success && resp.data) {
|
||||
setLangKeys(resp.data.filter((k) => k.isActive === "Y"));
|
||||
}
|
||||
} catch {
|
||||
if (alive) setLangKeys([]);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [form.companyCode]);
|
||||
|
||||
const isDirty =
|
||||
form.menuNameKor !== initial.menuNameKor ||
|
||||
form.menuUrl !== initial.menuUrl ||
|
||||
@@ -148,23 +196,31 @@ export function MenuSettingsPanel({ menu, parentName, onSaved, onDelete, onOpenA
|
||||
<div className="v5-mm-sv-row-2">
|
||||
<div className="v5-mm-sv-row">
|
||||
<label>다국어 키</label>
|
||||
<input
|
||||
<select
|
||||
className="v5-mm-inp"
|
||||
value={form.langKey}
|
||||
onChange={(e) => set("langKey", e.target.value)}
|
||||
placeholder="예) menu.management"
|
||||
/>
|
||||
>
|
||||
<option value="">(사용 안 함)</option>
|
||||
{langKeys.map((k) => (
|
||||
<option key={k.keyId} value={k.langKey}>
|
||||
{k.langKey}
|
||||
{k.description ? ` — ${k.description}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="v5-mm-sv-row">
|
||||
<label>아이콘</label>
|
||||
<input
|
||||
className="v5-mm-inp"
|
||||
<div className="v5-mm-iconpicker-slot">
|
||||
<MenuIconPicker
|
||||
value={form.menuIcon}
|
||||
onChange={(e) => set("menuIcon", e.target.value)}
|
||||
placeholder="Lucide 아이콘명"
|
||||
onChange={(iconName) => set("menuIcon", iconName)}
|
||||
label=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="v5-mm-sv-row">
|
||||
<label>설명</label>
|
||||
<textarea
|
||||
@@ -204,12 +260,19 @@ export function MenuSettingsPanel({ menu, parentName, onSaved, onDelete, onOpenA
|
||||
<div className="v5-mm-sv-row-2">
|
||||
<div className="v5-mm-sv-row">
|
||||
<label>회사</label>
|
||||
<input
|
||||
<select
|
||||
className="v5-mm-inp"
|
||||
value={form.companyCode}
|
||||
value={form.companyCode || "*"}
|
||||
onChange={(e) => set("companyCode", e.target.value)}
|
||||
placeholder="* 또는 회사 코드"
|
||||
/>
|
||||
>
|
||||
<option value="*">공용 (*)</option>
|
||||
{companies.map((c) => (
|
||||
<option key={c.company_code} value={c.company_code}>
|
||||
{c.company_name}
|
||||
{c.company_code ? ` (${c.company_code})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="v5-mm-sv-row">
|
||||
<label>부모</label>
|
||||
|
||||
@@ -184,8 +184,9 @@ const NodeRow: React.FC<RowProps> = ({
|
||||
</div>
|
||||
{node.lev === 1 && hasChildren && <span className="v5-mm-cnt">{node.children.length}</span>}
|
||||
</div>
|
||||
{open && hasChildren && (
|
||||
<div className="v5-mm-sub" style={{ display: "block" }}>
|
||||
{hasChildren && (
|
||||
<div className={`v5-mm-sub${open ? " is-open" : ""}`}>
|
||||
<div className="v5-mm-sub-inner">
|
||||
{node.children.map((child) => (
|
||||
<NodeRow
|
||||
key={child.id}
|
||||
@@ -203,6 +204,7 @@ const NodeRow: React.FC<RowProps> = ({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useRef, useCallback, useEffect, useState, forwardRef, type ReactNode } from 'react';
|
||||
import { Plus, Save, X } from 'lucide-react';
|
||||
import { useDashboardStore } from '@/stores/dashboardStore';
|
||||
import { useControlMode } from '@/components/control/hooks/useControlMode';
|
||||
import { deleteDashboardCard } from '@/lib/api/dashMenu';
|
||||
import { toast } from 'sonner';
|
||||
import { DashboardCard } from './DashboardCard';
|
||||
import { DashboardEmpty } from './DashboardEmpty';
|
||||
import { useRef, useCallback, useEffect, useState, forwardRef, type ReactNode } from "react";
|
||||
import { Plus, Save, X } from "lucide-react";
|
||||
import { useDashboardStore } from "@/stores/dashboardStore";
|
||||
import { useControlMode } from "@/components/control/hooks/useControlMode";
|
||||
import { deleteDashboardCard } from "@/lib/api/dashMenu";
|
||||
import { toast } from "sonner";
|
||||
import { DashboardCard } from "./DashboardCard";
|
||||
import { DashboardEmpty } from "./DashboardEmpty";
|
||||
|
||||
/**
|
||||
* AnimatedFab — enter/exit 애니메이션을 가진 플로팅 액션 바.
|
||||
@@ -31,7 +31,7 @@ function AnimatedFab({ show, children }: { show: boolean; children: ReactNode })
|
||||
}
|
||||
}, [show, rendered]);
|
||||
if (!rendered) return null;
|
||||
return <div className={`ud-fab${closing ? ' closing' : ''}`}>{children}</div>;
|
||||
return <div className={`ud-fab${closing ? "closing" : ""}`}>{children}</div>;
|
||||
}
|
||||
|
||||
interface DashboardCanvasProps {
|
||||
@@ -41,56 +41,364 @@ interface DashboardCanvasProps {
|
||||
controlMode?: boolean;
|
||||
}
|
||||
|
||||
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(function DashboardCanvas({
|
||||
dashboardName,
|
||||
onOpenLibrary,
|
||||
onOpenSettings,
|
||||
controlMode: controlActive,
|
||||
}, externalRef) {
|
||||
// ═══ 자유 배치 + 반응형을 위한 상수/유틸 ═══
|
||||
// 저장 단위: % (0~100). 기존 px 데이터는 로드 시 자동 변환됨.
|
||||
const SNAP_THRESHOLD = 8; // px — 스냅 걸리는 임계거리
|
||||
const MIN_CARD_W_PX = 220;
|
||||
const MIN_CARD_H_PX = 140;
|
||||
const INTERACTION_THRESHOLD = 4;
|
||||
|
||||
type Dir = "" | "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
|
||||
|
||||
// 값이 px 로 저장된 건지 판별 — % 는 0~100, px 는 150+ 가 일반
|
||||
function isValuePx(val: number): boolean {
|
||||
return val > 100;
|
||||
}
|
||||
|
||||
function pxToPct(px: number, container: number): number {
|
||||
if (!container || container <= 0) return 0;
|
||||
return (px / container) * 100;
|
||||
}
|
||||
|
||||
function pctToPx(pct: number, container: number): number {
|
||||
return (pct / 100) * container;
|
||||
}
|
||||
|
||||
function round3(n: number): number {
|
||||
return Math.round(n * 1000) / 1000;
|
||||
}
|
||||
|
||||
// 카드 좌표를 항상 px 로 해석 (% 면 container 로 환산)
|
||||
function readCardPx(card: Record<string, unknown>, cw: number, ch: number) {
|
||||
const x = Number(card.position_x ?? card.POSITION_X ?? 0);
|
||||
const y = Number(card.position_y ?? card.POSITION_Y ?? 0);
|
||||
const w = Number(card.width ?? card.WIDTH ?? 0);
|
||||
const h = Number(card.height ?? card.HEIGHT ?? 0);
|
||||
return {
|
||||
x: isValuePx(x) ? x : pctToPx(x, cw),
|
||||
y: isValuePx(y) ? y : pctToPx(y, ch),
|
||||
w: isValuePx(w) ? w : pctToPx(w, cw),
|
||||
h: isValuePx(h) ? h : pctToPx(h, ch),
|
||||
};
|
||||
}
|
||||
|
||||
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(function DashboardCanvas(
|
||||
{ dashboardName, onOpenLibrary, onOpenSettings, controlMode: controlActive },
|
||||
externalRef,
|
||||
) {
|
||||
const cards = useDashboardStore((s) => s.cards);
|
||||
const editMode = useDashboardStore((s) => s.editMode);
|
||||
const setEditMode = useDashboardStore((s) => s.setEditMode);
|
||||
const updateCard = useDashboardStore((s) => s.updateCard);
|
||||
const removeCard = useDashboardStore((s) => s.removeCard);
|
||||
const setCards = useDashboardStore((s) => s.setCards);
|
||||
const activeDashboardId = useDashboardStore((s) => s.activeDashboardId);
|
||||
const toggleControlMode = useControlMode((s) => s.toggleControlMode);
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = (externalRef as React.RefObject<HTMLDivElement | null>) ?? internalRef;
|
||||
const [canvasSize, setCanvasSize] = useState({ cw: 0, ch: 0 });
|
||||
|
||||
// 스냅 가이드 라인 (시각 피드백)
|
||||
const [guides, setGuides] = useState<{ v: number[]; h: number[] }>({ v: [], h: [] });
|
||||
|
||||
const dragRef = useRef<{
|
||||
cardId: string;
|
||||
startX: number; startY: number;
|
||||
origLeft: number; origTop: number;
|
||||
origW: number; origH: number;
|
||||
mode: 'drag' | 'resize';
|
||||
startX: number;
|
||||
startY: number;
|
||||
origLeft: number;
|
||||
origTop: number; // px
|
||||
origW: number;
|
||||
origH: number; // px
|
||||
liveLeft: number;
|
||||
liveTop: number;
|
||||
liveW: number;
|
||||
liveH: number;
|
||||
hasMoved: boolean;
|
||||
activated: boolean;
|
||||
mode: "drag" | "resize";
|
||||
dir: Dir;
|
||||
el: HTMLElement;
|
||||
} | null>(null);
|
||||
|
||||
// 캔버스 경계 clamp
|
||||
const clamp = useCallback((l: number, t: number, w: number, h: number) => {
|
||||
const getCanvasSize = useCallback(() => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return { l, t, w, h };
|
||||
const cw = cv.clientWidth;
|
||||
const ch = cv.clientHeight;
|
||||
w = Math.min(w, cw);
|
||||
h = Math.min(h, ch);
|
||||
l = Math.max(0, Math.min(l, cw - w));
|
||||
t = Math.max(0, Math.min(t, ch - h));
|
||||
return { l, t, w, h };
|
||||
}, []);
|
||||
const rect = cv?.getBoundingClientRect();
|
||||
return {
|
||||
cw: rect?.width || cv?.clientWidth || canvasSize.cw || 1000,
|
||||
ch: rect?.height || cv?.clientHeight || canvasSize.ch || 600,
|
||||
};
|
||||
}, [canvasRef, canvasSize.ch, canvasSize.cw]);
|
||||
|
||||
// 마우스 다운 → 드래그/리사이즈 시작
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
useEffect(() => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
|
||||
const syncSize = () => {
|
||||
const rect = cv.getBoundingClientRect();
|
||||
setCanvasSize({
|
||||
cw: rect.width || cv.clientWidth,
|
||||
ch: rect.height || cv.clientHeight,
|
||||
});
|
||||
};
|
||||
|
||||
syncSize();
|
||||
|
||||
const ro = new ResizeObserver(() => syncSize());
|
||||
ro.observe(cv);
|
||||
window.addEventListener("resize", syncSize);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", syncSize);
|
||||
};
|
||||
}, [canvasRef]);
|
||||
|
||||
// 카드 값 정규화 — px → %, 범위 clamp (5~100, x+w≤100, y+h≤100)
|
||||
// px 데이터 마이그레이션과 오버플로우/NaN 보정을 동시에 처리.
|
||||
// changed=false 면 setCards 안 부르므로 무한루프 방지.
|
||||
useEffect(() => {
|
||||
if (cards.length === 0) return;
|
||||
const { cw, ch } = canvasSize;
|
||||
if (cw <= 0 || ch <= 0) return;
|
||||
|
||||
let changed = false;
|
||||
const migrated = cards.map((c) => {
|
||||
const x = Number(c.position_x ?? c.POSITION_X ?? 5);
|
||||
const y = Number(c.position_y ?? c.POSITION_Y ?? 5);
|
||||
const w = Number(c.width ?? c.WIDTH ?? 40);
|
||||
const h = Number(c.height ?? c.HEIGHT ?? 35);
|
||||
|
||||
// 1) px → % (NaN 이면 기본값)
|
||||
let nx = Number.isFinite(x) ? (isValuePx(x) ? pxToPct(x, cw) : x) : 5;
|
||||
let ny = Number.isFinite(y) ? (isValuePx(y) ? pxToPct(y, ch) : y) : 5;
|
||||
let nw = Number.isFinite(w) ? (isValuePx(w) ? pxToPct(w, cw) : w) : 40;
|
||||
let nh = Number.isFinite(h) ? (isValuePx(h) ? pxToPct(h, ch) : h) : 35;
|
||||
|
||||
// 2) 범위 clamp — 카드가 항상 캔버스 안에 들어오도록
|
||||
nw = Math.max(5, Math.min(nw, 100));
|
||||
nh = Math.max(5, Math.min(nh, 100));
|
||||
nx = Math.max(0, Math.min(nx, 100 - nw));
|
||||
ny = Math.max(0, Math.min(ny, 100 - nh));
|
||||
|
||||
const changedThis =
|
||||
!Number.isFinite(x) ||
|
||||
!Number.isFinite(y) ||
|
||||
!Number.isFinite(w) ||
|
||||
!Number.isFinite(h) ||
|
||||
Math.abs(nx - x) > 0.01 ||
|
||||
Math.abs(ny - y) > 0.01 ||
|
||||
Math.abs(nw - w) > 0.01 ||
|
||||
Math.abs(nh - h) > 0.01;
|
||||
if (changedThis) {
|
||||
changed = true;
|
||||
return { ...c, position_x: nx, position_y: ny, width: nw, height: nh };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
if (changed) setCards(migrated);
|
||||
}, [cards, canvasSize, setCards]);
|
||||
|
||||
// 드래그 clamp + 스냅 — 리턴은 px
|
||||
const applyDragConstraints = useCallback(
|
||||
(cardId: string, newLeft: number, newTop: number, w: number, h: number) => {
|
||||
const { cw, ch } = getCanvasSize();
|
||||
// 카드가 캔버스보다 크면(마이그레이션 미동작 등 edge) 왼쪽/위 경계로 강제 이동 금지 — origin 유지
|
||||
const maxL = Math.max(0, cw - w);
|
||||
const maxT = Math.max(0, ch - h);
|
||||
let l = w > cw ? newLeft : Math.max(0, Math.min(newLeft, maxL));
|
||||
let t = h > ch ? newTop : Math.max(0, Math.min(newTop, maxT));
|
||||
|
||||
const vGuides: number[] = [];
|
||||
const hGuides: number[] = [];
|
||||
|
||||
// 1) 다른 카드 가장자리 스냅
|
||||
for (const o of cards) {
|
||||
const oid = o.card_id ?? o.CARD_ID;
|
||||
if (oid === cardId) continue;
|
||||
const { x: oL, y: oT, w: oW, h: oH } = readCardPx(o, cw, ch);
|
||||
const oR = oL + oW;
|
||||
const oB = oT + oH;
|
||||
|
||||
// X: left↔otherRight, right↔otherLeft, left↔otherLeft, right↔otherRight
|
||||
if (Math.abs(l - oR) < SNAP_THRESHOLD) {
|
||||
l = oR;
|
||||
vGuides.push(l);
|
||||
} else if (Math.abs(l + w - oL) < SNAP_THRESHOLD) {
|
||||
l = oL - w;
|
||||
vGuides.push(oL);
|
||||
} else if (Math.abs(l - oL) < SNAP_THRESHOLD) {
|
||||
l = oL;
|
||||
vGuides.push(l);
|
||||
} else if (Math.abs(l + w - oR) < SNAP_THRESHOLD) {
|
||||
l = oR - w;
|
||||
vGuides.push(oR);
|
||||
}
|
||||
|
||||
// Y
|
||||
if (Math.abs(t - oB) < SNAP_THRESHOLD) {
|
||||
t = oB;
|
||||
hGuides.push(t);
|
||||
} else if (Math.abs(t + h - oT) < SNAP_THRESHOLD) {
|
||||
t = oT - h;
|
||||
hGuides.push(oT);
|
||||
} else if (Math.abs(t - oT) < SNAP_THRESHOLD) {
|
||||
t = oT;
|
||||
hGuides.push(t);
|
||||
} else if (Math.abs(t + h - oB) < SNAP_THRESHOLD) {
|
||||
t = oB - h;
|
||||
hGuides.push(oB);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 캔버스 경계 스냅 (0 / cw / ch)
|
||||
if (Math.abs(l) < SNAP_THRESHOLD) {
|
||||
l = 0;
|
||||
vGuides.push(0);
|
||||
}
|
||||
if (Math.abs(l + w - cw) < SNAP_THRESHOLD) {
|
||||
l = cw - w;
|
||||
vGuides.push(cw);
|
||||
}
|
||||
if (Math.abs(t) < SNAP_THRESHOLD) {
|
||||
t = 0;
|
||||
hGuides.push(0);
|
||||
}
|
||||
if (Math.abs(t + h - ch) < SNAP_THRESHOLD) {
|
||||
t = ch - h;
|
||||
hGuides.push(ch);
|
||||
}
|
||||
|
||||
// 3) 최종 clamp — 카드가 캔버스에 맞는 경우만 경계로 clamp, 오버플로우 카드는 그대로 두기
|
||||
if (w <= cw) l = Math.max(0, Math.min(l, cw - w));
|
||||
if (h <= ch) t = Math.max(0, Math.min(t, ch - h));
|
||||
|
||||
return { l, t, vGuides, hGuides };
|
||||
},
|
||||
[cards, getCanvasSize],
|
||||
);
|
||||
|
||||
// 리사이즈 clamp (방향별) — 캔버스 경계를 벗어나지 않게, 최소 크기 보장
|
||||
// 오버플로우 카드 edge: 이미 카드가 캔버스보다 크면 추가 확장은 금지, 축소는 자유롭게.
|
||||
const applyResizeConstraints = useCallback(
|
||||
(cardId: string, origL: number, origT: number, origW: number, origH: number, dx: number, dy: number, dir: Dir) => {
|
||||
const { cw, ch } = getCanvasSize();
|
||||
let l = origL,
|
||||
t = origT,
|
||||
w = origW,
|
||||
h = origH;
|
||||
const vGuides: number[] = [];
|
||||
const hGuides: number[] = [];
|
||||
|
||||
if (dir.includes("e")) {
|
||||
// 확장 한계: 캔버스 오른쪽 경계. 이미 넘치고 있으면 현재 origW 를 한계로 (확장 금지, 축소 허용)
|
||||
const maxW = Math.max(origW, cw - origL);
|
||||
w = Math.max(MIN_CARD_W_PX, Math.min(origW + dx, maxW));
|
||||
}
|
||||
if (dir.includes("s")) {
|
||||
const maxH = Math.max(origH, ch - origT);
|
||||
h = Math.max(MIN_CARD_H_PX, Math.min(origH + dy, maxH));
|
||||
}
|
||||
if (dir.includes("w")) {
|
||||
// w 핸들: dx 만큼 left 증가 + width 감소. 왼쪽 경계(0) 허용 범위 내에서.
|
||||
const minDx = origL > 0 ? -origL : 0; // 캔버스 안에 있을 때만 왼쪽으로 확장 허용
|
||||
const maxShrink = origW - MIN_CARD_W_PX;
|
||||
const clampedDx = Math.max(minDx, Math.min(dx, maxShrink));
|
||||
l = origL + clampedDx;
|
||||
w = origW - clampedDx;
|
||||
}
|
||||
if (dir.includes("n")) {
|
||||
const minDy = origT > 0 ? -origT : 0;
|
||||
const maxShrink = origH - MIN_CARD_H_PX;
|
||||
const clampedDy = Math.max(minDy, Math.min(dy, maxShrink));
|
||||
t = origT + clampedDy;
|
||||
h = origH - clampedDy;
|
||||
}
|
||||
|
||||
let left = l;
|
||||
let top = t;
|
||||
let right = l + w;
|
||||
let bottom = t + h;
|
||||
|
||||
const snapValue = (value: number, targets: number[]) => {
|
||||
let snapped = value;
|
||||
let matched = false;
|
||||
for (const target of targets) {
|
||||
if (Math.abs(value - target) < SNAP_THRESHOLD) {
|
||||
snapped = target;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { snapped, matched };
|
||||
};
|
||||
|
||||
const xTargets = [0, cw];
|
||||
const yTargets = [0, ch];
|
||||
for (const o of cards) {
|
||||
const oid = o.card_id ?? o.CARD_ID;
|
||||
if (oid === cardId) continue;
|
||||
const { x: oL, y: oT, w: oW, h: oH } = readCardPx(o, cw, ch);
|
||||
xTargets.push(oL, oL + oW);
|
||||
yTargets.push(oT, oT + oH);
|
||||
}
|
||||
|
||||
if (dir.includes("e")) {
|
||||
const { snapped, matched } = snapValue(right, xTargets);
|
||||
if (matched && snapped - left >= MIN_CARD_W_PX) {
|
||||
right = snapped;
|
||||
vGuides.push(snapped);
|
||||
}
|
||||
}
|
||||
if (dir.includes("w")) {
|
||||
const { snapped, matched } = snapValue(left, xTargets);
|
||||
if (matched && right - snapped >= MIN_CARD_W_PX) {
|
||||
left = snapped;
|
||||
vGuides.push(snapped);
|
||||
}
|
||||
}
|
||||
if (dir.includes("s")) {
|
||||
const { snapped, matched } = snapValue(bottom, yTargets);
|
||||
if (matched && snapped - top >= MIN_CARD_H_PX) {
|
||||
bottom = snapped;
|
||||
hGuides.push(snapped);
|
||||
}
|
||||
}
|
||||
if (dir.includes("n")) {
|
||||
const { snapped, matched } = snapValue(top, yTargets);
|
||||
if (matched && bottom - snapped >= MIN_CARD_H_PX) {
|
||||
top = snapped;
|
||||
hGuides.push(snapped);
|
||||
}
|
||||
}
|
||||
|
||||
l = left;
|
||||
t = top;
|
||||
w = right - left;
|
||||
h = bottom - top;
|
||||
|
||||
return { l, t, w, h, vGuides, hGuides };
|
||||
},
|
||||
[cards, getCanvasSize],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!editMode) return;
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// 버튼/입력/select 클릭은 무시 (단, 리사이즈 핸들은 통과)
|
||||
const isResize = target.closest('[data-resize]') !== null;
|
||||
const resizeEl = target.closest("[data-resize]") as HTMLElement | null;
|
||||
const isResize = !!resizeEl;
|
||||
if (!isResize) {
|
||||
if (target.closest('button') || target.closest('input') || target.closest('select') || target.closest('textarea')) return;
|
||||
if (
|
||||
target.closest("button") ||
|
||||
target.closest("input") ||
|
||||
target.closest("select") ||
|
||||
target.closest("textarea")
|
||||
)
|
||||
return;
|
||||
if (!target.closest(".dash-card")) return;
|
||||
}
|
||||
|
||||
// ★ wrapper div(data-card-id 가진 것)를 찾아야 함 — .dash-card는 그 안의 div
|
||||
const wrapperEl = target.closest('[data-card-id]') as HTMLElement;
|
||||
const wrapperEl = target.closest("[data-card-id]") as HTMLElement;
|
||||
if (!wrapperEl) return;
|
||||
|
||||
const cardId = wrapperEl.dataset.cardId;
|
||||
@@ -98,26 +406,46 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const dir = (resizeEl?.dataset.resize ?? "") as Dir;
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
const wrapperRect = wrapperEl.getBoundingClientRect();
|
||||
const box =
|
||||
canvasRect && wrapperRect.width > 0 && wrapperRect.height > 0
|
||||
? {
|
||||
x: wrapperRect.left - canvasRect.left,
|
||||
y: wrapperRect.top - canvasRect.top,
|
||||
w: wrapperRect.width,
|
||||
h: wrapperRect.height,
|
||||
}
|
||||
: {
|
||||
x: wrapperEl.offsetLeft,
|
||||
y: wrapperEl.offsetTop,
|
||||
w: wrapperEl.offsetWidth,
|
||||
h: wrapperEl.offsetHeight,
|
||||
};
|
||||
|
||||
dragRef.current = {
|
||||
cardId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origLeft: wrapperEl.offsetLeft,
|
||||
origTop: wrapperEl.offsetTop,
|
||||
origW: wrapperEl.offsetWidth,
|
||||
origH: wrapperEl.offsetHeight,
|
||||
mode: isResize ? 'resize' : 'drag',
|
||||
origLeft: box.x,
|
||||
origTop: box.y,
|
||||
origW: box.w,
|
||||
origH: box.h,
|
||||
liveLeft: box.x,
|
||||
liveTop: box.y,
|
||||
liveW: box.w,
|
||||
liveH: box.h,
|
||||
hasMoved: false,
|
||||
activated: false,
|
||||
mode: isResize ? "resize" : "drag",
|
||||
dir: isResize ? dir : "",
|
||||
el: wrapperEl,
|
||||
};
|
||||
},
|
||||
[canvasRef, editMode],
|
||||
);
|
||||
|
||||
// 시각 피드백은 내부 .dash-card에 적용
|
||||
const cardEl = wrapperEl.querySelector('.dash-card') as HTMLElement | null;
|
||||
if (cardEl) cardEl.classList.add(isResize ? 'resizing' : 'dragging');
|
||||
document.body.style.cursor = isResize ? 'nwse-resize' : 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, [editMode]);
|
||||
|
||||
// 마우스 이동
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
@@ -125,17 +453,66 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
|
||||
const dx = e.clientX - d.startX;
|
||||
const dy = e.clientY - d.startY;
|
||||
if (!d.hasMoved && Math.max(Math.abs(dx), Math.abs(dy)) < INTERACTION_THRESHOLD) return;
|
||||
d.hasMoved = true;
|
||||
if (!d.activated) {
|
||||
d.activated = true;
|
||||
const cardEl = d.el.querySelector(".dash-card") as HTMLElement | null;
|
||||
if (cardEl) cardEl.classList.add(d.mode === "resize" ? "resizing" : "dragging");
|
||||
d.el.style.left = d.origLeft + "px";
|
||||
d.el.style.top = d.origTop + "px";
|
||||
d.el.style.width = d.origW + "px";
|
||||
d.el.style.height = d.origH + "px";
|
||||
|
||||
if (d.mode === 'drag') {
|
||||
const c = clamp(d.origLeft + dx, d.origTop + dy, d.origW, d.origH);
|
||||
d.el.style.left = c.l + 'px';
|
||||
d.el.style.top = c.t + 'px';
|
||||
const cursorMap: Record<string, string> = {
|
||||
n: "ns-resize",
|
||||
s: "ns-resize",
|
||||
e: "ew-resize",
|
||||
w: "ew-resize",
|
||||
ne: "nesw-resize",
|
||||
sw: "nesw-resize",
|
||||
nw: "nwse-resize",
|
||||
se: "nwse-resize",
|
||||
};
|
||||
document.body.style.cursor = d.mode === "resize" ? (cursorMap[d.dir] ?? "nwse-resize") : "grabbing";
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
if (d.mode === "drag") {
|
||||
const { l, t, vGuides, hGuides } = applyDragConstraints(
|
||||
d.cardId,
|
||||
d.origLeft + dx,
|
||||
d.origTop + dy,
|
||||
d.origW,
|
||||
d.origH,
|
||||
);
|
||||
d.liveLeft = l;
|
||||
d.liveTop = t;
|
||||
d.liveW = d.origW;
|
||||
d.liveH = d.origH;
|
||||
d.el.style.left = l + "px";
|
||||
d.el.style.top = t + "px";
|
||||
setGuides({ v: vGuides, h: hGuides });
|
||||
} else {
|
||||
const nw = Math.max(220, d.origW + dx);
|
||||
const nh = Math.max(140, d.origH + dy);
|
||||
const c = clamp(d.origLeft, d.origTop, nw, nh);
|
||||
d.el.style.width = c.w + 'px';
|
||||
d.el.style.height = c.h + 'px';
|
||||
const { l, t, w, h, vGuides, hGuides } = applyResizeConstraints(
|
||||
d.cardId,
|
||||
d.origLeft,
|
||||
d.origTop,
|
||||
d.origW,
|
||||
d.origH,
|
||||
dx,
|
||||
dy,
|
||||
d.dir,
|
||||
);
|
||||
d.liveLeft = l;
|
||||
d.liveTop = t;
|
||||
d.liveW = w;
|
||||
d.liveH = h;
|
||||
d.el.style.left = l + "px";
|
||||
d.el.style.top = t + "px";
|
||||
d.el.style.width = w + "px";
|
||||
d.el.style.height = h + "px";
|
||||
setGuides({ v: vGuides, h: hGuides });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,86 +520,129 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
|
||||
// 시각 피드백 제거 (.dash-card)
|
||||
const cardEl = d.el.querySelector('.dash-card') as HTMLElement | null;
|
||||
if (cardEl) cardEl.classList.remove('dragging', 'resizing');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
|
||||
// 최종 위치를 store에 반영
|
||||
if (!d.hasMoved) {
|
||||
dragRef.current = null;
|
||||
setGuides({ v: [], h: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const cardEl = d.el.querySelector(".dash-card") as HTMLElement | null;
|
||||
if (cardEl) cardEl.classList.remove("dragging", "resizing");
|
||||
|
||||
// px → % 변환해서 store 에 저장 (다음 render 에서 % 로 다시 그려짐)
|
||||
const { cw, ch } = getCanvasSize();
|
||||
if (d.mode === "drag") {
|
||||
const finalW = d.el.getBoundingClientRect().width || d.origW;
|
||||
const finalH = d.el.getBoundingClientRect().height || d.origH;
|
||||
updateCard(d.cardId, {
|
||||
position_x: d.el.offsetLeft,
|
||||
position_y: d.el.offsetTop,
|
||||
width: d.el.offsetWidth,
|
||||
height: d.el.offsetHeight,
|
||||
position_x: round3(pxToPct(d.liveLeft, cw)),
|
||||
position_y: round3(pxToPct(d.liveTop, ch)),
|
||||
width: round3(pxToPct(finalW, cw)),
|
||||
height: round3(pxToPct(finalH, ch)),
|
||||
});
|
||||
} else {
|
||||
updateCard(d.cardId, {
|
||||
position_x: round3(pxToPct(d.liveLeft, cw)),
|
||||
position_y: round3(pxToPct(d.liveTop, ch)),
|
||||
width: round3(pxToPct(d.liveW, cw)),
|
||||
height: round3(pxToPct(d.liveH, ch)),
|
||||
});
|
||||
}
|
||||
|
||||
dragRef.current = null;
|
||||
setGuides({ v: [], h: [] });
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [clamp, updateCard]);
|
||||
}, [applyDragConstraints, applyResizeConstraints, getCanvasSize, updateCard]);
|
||||
|
||||
const handleToggleCollapse = useCallback((cardId: string) => {
|
||||
const handleToggleCollapse = useCallback(
|
||||
(cardId: string) => {
|
||||
const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId);
|
||||
if (!card) return;
|
||||
const wasCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false;
|
||||
updateCard(cardId, { is_collapsed: !wasCollapsed });
|
||||
}, [cards, updateCard]);
|
||||
},
|
||||
[cards, updateCard],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(async (cardId: string) => {
|
||||
if (!confirm('이 카드를 삭제하시겠습니까?')) return;
|
||||
const handleRemove = useCallback(
|
||||
async (cardId: string) => {
|
||||
if (!confirm("이 카드를 삭제하시겠습니까?")) return;
|
||||
if (!activeDashboardId) {
|
||||
toast.error('활성 대시보드가 없습니다');
|
||||
toast.error("활성 대시보드가 없습니다");
|
||||
return;
|
||||
}
|
||||
// 낙관적 UI: 먼저 로컬에서 제거 후 서버 호출. 실패 시 알림.
|
||||
removeCard(cardId);
|
||||
try {
|
||||
await deleteDashboardCard(activeDashboardId, cardId);
|
||||
toast.success('카드 삭제됨');
|
||||
toast.success("카드 삭제됨");
|
||||
} catch (err) {
|
||||
console.error('[Dashboard] 카드 삭제 실패', err);
|
||||
toast.error('카드 삭제 실패 — 새로고침 후 다시 시도해주세요');
|
||||
console.error("[Dashboard] 카드 삭제 실패", err);
|
||||
toast.error("카드 삭제 실패 — 새로고침 후 다시 시도해주세요");
|
||||
}
|
||||
}, [activeDashboardId, removeCard]);
|
||||
},
|
||||
[activeDashboardId, removeCard],
|
||||
);
|
||||
|
||||
const handleRequestSave = useCallback(() => {
|
||||
// 헤더/FAB 공용 저장 트리거 — DashboardLayout 가 수신해 실제 저장 실행
|
||||
window.dispatchEvent(new CustomEvent('dash:save'));
|
||||
window.dispatchEvent(new CustomEvent("dash:save"));
|
||||
}, []);
|
||||
|
||||
const canvasClassName = ["dash-canvas", editMode && "edit-mode", controlActive && "control-mode"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={`dash-canvas${editMode ? ' edit-mode' : ''}${controlActive ? ' control-mode' : ''}`}
|
||||
onMouseDown={controlActive ? undefined : handleMouseDown}
|
||||
>
|
||||
<div ref={canvasRef} className={canvasClassName} onMouseDown={controlActive ? undefined : handleMouseDown}>
|
||||
{cards.length === 0 ? (
|
||||
<DashboardEmpty dashboardName={dashboardName} onOpenLibrary={onOpenLibrary} />
|
||||
) : (
|
||||
cards.map((card) => {
|
||||
const id = card.card_id ?? card.CARD_ID;
|
||||
const x = Number(card.position_x ?? card.POSITION_X ?? 50);
|
||||
const y = Number(card.position_y ?? card.POSITION_Y ?? 50);
|
||||
const w = Number(card.width ?? card.WIDTH ?? 600);
|
||||
const h = Number(card.height ?? card.HEIGHT ?? 400);
|
||||
const xRaw = Number(card.position_x ?? card.POSITION_X ?? 5);
|
||||
const yRaw = Number(card.position_y ?? card.POSITION_Y ?? 5);
|
||||
const wRaw = Number(card.width ?? card.WIDTH ?? 40);
|
||||
const hRaw = Number(card.height ?? card.HEIGHT ?? 35);
|
||||
|
||||
// 마이그레이션 전 px 데이터는 px 로 (곧 useEffect 가 % 로 전환), 그 외는 % 로 렌더
|
||||
const asPx = isValuePx(xRaw) || isValuePx(yRaw) || isValuePx(wRaw) || isValuePx(hRaw);
|
||||
const unit = asPx ? "px" : "%";
|
||||
|
||||
// % 렌더 시 안전망 clamp (NaN / 오버플로우 방어)
|
||||
let x = xRaw,
|
||||
y = yRaw,
|
||||
w = wRaw,
|
||||
h = hRaw;
|
||||
if (!asPx) {
|
||||
if (!Number.isFinite(x)) x = 5;
|
||||
if (!Number.isFinite(y)) y = 5;
|
||||
if (!Number.isFinite(w)) w = 40;
|
||||
if (!Number.isFinite(h)) h = 35;
|
||||
w = Math.max(5, Math.min(w, 100));
|
||||
h = Math.max(5, Math.min(h, 100));
|
||||
x = Math.max(0, Math.min(x, 100 - w));
|
||||
y = Math.max(0, Math.min(y, 100 - h));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
data-card-id={id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x + 'px',
|
||||
top: y + 'px',
|
||||
width: w + 'px',
|
||||
height: h + 'px',
|
||||
position: "absolute",
|
||||
left: x + unit,
|
||||
top: y + unit,
|
||||
width: w + unit,
|
||||
height: h + unit,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
@@ -238,6 +658,14 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 스냅 가이드 — 드래그 중 달라붙는 지점 시각 표시 */}
|
||||
{guides.v.map((x, i) => (
|
||||
<div key={`gv-${i}`} className="dash-snap-guide v" style={{ left: x + "px", top: 0, bottom: 0 }} />
|
||||
))}
|
||||
{guides.h.map((y, i) => (
|
||||
<div key={`gh-${i}`} className="dash-snap-guide h" style={{ top: y + "px", left: 0, right: 0 }} />
|
||||
))}
|
||||
|
||||
{/* 편집 모드 FAB — 캔버스 우하단 */}
|
||||
<AnimatedFab show={editMode && !controlActive}>
|
||||
<span className="ud-fab-badge">
|
||||
|
||||
@@ -4,12 +4,36 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { RefreshCw, ChevronDown, X, Settings } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getTemplateInfo } from '@/lib/api/template';
|
||||
import { fcList, fcInsert, fcUpdate, fcDelete } from '@/lib/api/fcData';
|
||||
import { FcForm } from '@/components/fc';
|
||||
import { fcList, fcDelete } from '@/lib/api/fcData';
|
||||
import type { FieldConfig, Template } from '@/types/invyone-component';
|
||||
import { CardMiniView } from './CardMiniView';
|
||||
import { TemplateRenderer, type TemplateRenderContext } from './TemplateRenderer';
|
||||
|
||||
/**
|
||||
* 뷰별 팝업 크기 계산 — 빌더(ScreenDesigner)가 저장한
|
||||
* template.views.screenResolutions.{create|edit} 를 우선 사용.
|
||||
* 없으면 공통 screenResolution, 그것도 없으면 900×700 폴백.
|
||||
*/
|
||||
function computePopupFeatures(
|
||||
template: Template | null,
|
||||
view: 'create' | 'edit',
|
||||
): string {
|
||||
const viewsObj = (template?.views as any) ?? {};
|
||||
const res =
|
||||
viewsObj?.screenResolutions?.[view] ??
|
||||
viewsObj?.screen_resolutions?.[view] ??
|
||||
viewsObj?.screenResolution ??
|
||||
viewsObj?.screen_resolution;
|
||||
const width = Math.max(400, Math.round(Number(res?.width) || 900));
|
||||
const height = Math.max(400, Math.round(Number(res?.height) || 700));
|
||||
return `width=${width},height=${height},resizable=yes,scrollbars=yes`;
|
||||
}
|
||||
|
||||
/** 팝업 고유 key + opener name — 여러 팝업 동시 허용 */
|
||||
function newPopupKey(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
interface DashboardCardProps {
|
||||
card: Record<string, any>;
|
||||
editMode: boolean;
|
||||
@@ -45,11 +69,9 @@ export function DashboardCard({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRow, setSelectedRow] = useState<Record<string, any> | null>(null);
|
||||
|
||||
// CRUD 모달: 'create' | 'edit' | null
|
||||
const [formMode, setFormMode] = useState<'create' | 'edit' | null>(null);
|
||||
const [formRow, setFormRow] = useState<Record<string, any> | null>(null);
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
/** 이 카드가 연 팝업의 key 집합 — 저장 알림이 자기 카드에서 온 건지 구분용 */
|
||||
const popupKeysRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Template + fields 로드
|
||||
useEffect(() => {
|
||||
@@ -137,24 +159,65 @@ export function DashboardCard({
|
||||
return pkField?.column ?? '';
|
||||
}, [fields]);
|
||||
|
||||
/** 공통 — 팝업 창 열기. 뷰별 해상도 + 초기 데이터 localStorage 시드 + 고유 key */
|
||||
const openFormPopup = useCallback(
|
||||
(mode: 'create' | 'edit', initialRow: Record<string, any>) => {
|
||||
if (!templateId) {
|
||||
toast.error('templateId 가 없습니다');
|
||||
return;
|
||||
}
|
||||
if (!primaryTable) {
|
||||
toast.error('primary_table 이 설정되어 있지 않습니다');
|
||||
return;
|
||||
}
|
||||
const key = newPopupKey();
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`form-popup:${key}`,
|
||||
JSON.stringify({
|
||||
initialRow,
|
||||
primaryTable,
|
||||
templateName,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* storage 실패해도 팝업은 열어줌 — 팝업 쪽에서 빈 데이터로 시작 */
|
||||
}
|
||||
popupKeysRef.current.add(key);
|
||||
const url = `/form-popup?templateId=${encodeURIComponent(
|
||||
String(templateId),
|
||||
)}&mode=${mode}&key=${encodeURIComponent(key)}`;
|
||||
const features = computePopupFeatures(template, mode);
|
||||
const win = window.open(url, `popup-${key}`, features);
|
||||
if (!win) {
|
||||
toast.error('팝업이 차단되었습니다. 브라우저 팝업 허용을 확인하세요.');
|
||||
popupKeysRef.current.delete(key);
|
||||
try {
|
||||
localStorage.removeItem(`form-popup:${key}`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
},
|
||||
[templateId, primaryTable, templateName, template],
|
||||
);
|
||||
|
||||
// CRUD 액션
|
||||
const handleAdd = useCallback(() => {
|
||||
const defaults: Record<string, any> = {};
|
||||
for (const f of fields) {
|
||||
if (f.defaultValue !== undefined) defaults[f.column] = f.defaultValue;
|
||||
}
|
||||
setFormRow(defaults);
|
||||
setFormMode('create');
|
||||
}, [fields]);
|
||||
openFormPopup('create', defaults);
|
||||
}, [fields, openFormPopup]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
if (!selectedRow) {
|
||||
toast.warning('수정할 행을 선택하세요');
|
||||
return;
|
||||
}
|
||||
setFormRow({ ...selectedRow });
|
||||
setFormMode('edit');
|
||||
}, [selectedRow]);
|
||||
openFormPopup('edit', { ...selectedRow });
|
||||
}, [selectedRow, openFormPopup]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!selectedRow) {
|
||||
@@ -179,99 +242,61 @@ export function DashboardCard({
|
||||
}
|
||||
}, [selectedRow, pkColumn, primaryTable, loadData]);
|
||||
|
||||
const handleSubmitForm = useCallback(
|
||||
async (row: Record<string, any>) => {
|
||||
try {
|
||||
if (formMode === 'create') {
|
||||
await fcInsert(primaryTable, row);
|
||||
toast.success('등록되었습니다');
|
||||
} else if (formMode === 'edit') {
|
||||
if (!pkColumn) throw new Error('PK 컬럼이 없습니다');
|
||||
const id = String(row[pkColumn] ?? '');
|
||||
if (!id) throw new Error('PK 값이 비어있습니다');
|
||||
await fcUpdate(primaryTable, id, row);
|
||||
toast.success('수정되었습니다');
|
||||
}
|
||||
setFormMode(null);
|
||||
setFormRow(null);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
console.error('[DashboardCard] 저장 실패:', err);
|
||||
toast.error(err?.message ?? '저장 실패');
|
||||
}
|
||||
},
|
||||
[formMode, primaryTable, pkColumn, loadData],
|
||||
);
|
||||
/** 팝업 창에서 저장 성공 알림 받으면 리로드 */
|
||||
useEffect(() => {
|
||||
const handler = (ev: MessageEvent) => {
|
||||
if (ev.origin !== window.location.origin) return;
|
||||
const data = ev.data;
|
||||
if (!data || data.type !== 'form-popup-saved') return;
|
||||
const key = String(data.key ?? '');
|
||||
if (!popupKeysRef.current.has(key)) return; // 다른 카드의 팝업이면 무시
|
||||
popupKeysRef.current.delete(key);
|
||||
loadData();
|
||||
toast.success(data.mode === 'edit' ? '수정되었습니다' : '등록되었습니다');
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
}, [loadData]);
|
||||
|
||||
const closeForm = useCallback(() => {
|
||||
setFormMode(null);
|
||||
setFormRow(null);
|
||||
}, []);
|
||||
|
||||
// 폼 row 패치 핸들러 (등록/수정 뷰 input 바인딩용)
|
||||
const handleFormRowChange = useCallback((patch: Record<string, any>) => {
|
||||
setFormRow((prev) => ({ ...(prev ?? {}), ...patch }));
|
||||
}, []);
|
||||
|
||||
const handleFormSubmit = useCallback(() => {
|
||||
if (formRow) handleSubmitForm(formRow);
|
||||
}, [formRow, handleSubmitForm]);
|
||||
|
||||
// TemplateRenderer 로 전달할 공유 context
|
||||
// TemplateRenderer 로 전달할 공유 context — 목록 뷰 전용.
|
||||
// 등록/수정 폼 바인딩은 별도 팝업 창(/form-popup)이 독립 관리.
|
||||
const renderContext: TemplateRenderContext = useMemo(
|
||||
() => ({
|
||||
fields,
|
||||
data,
|
||||
loading,
|
||||
primaryTable,
|
||||
selectedRow,
|
||||
totalCount,
|
||||
page,
|
||||
pageSize,
|
||||
searchParams,
|
||||
onSearch: handleSearch,
|
||||
onRowSelect: handleRowSelect,
|
||||
onPageChange: handlePageChange,
|
||||
onAdd: handleAdd,
|
||||
onEdit: handleEdit,
|
||||
onDelete: handleDelete,
|
||||
formRow: formRow ?? undefined,
|
||||
onFormRowChange: handleFormRowChange,
|
||||
onFormSubmit: handleFormSubmit,
|
||||
onFormCancel: closeForm,
|
||||
}),
|
||||
[
|
||||
fields,
|
||||
data,
|
||||
loading,
|
||||
primaryTable,
|
||||
selectedRow,
|
||||
totalCount,
|
||||
page,
|
||||
pageSize,
|
||||
searchParams,
|
||||
handleSearch,
|
||||
handleRowSelect,
|
||||
handlePageChange,
|
||||
handleAdd,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
formRow,
|
||||
handleFormRowChange,
|
||||
handleFormSubmit,
|
||||
closeForm,
|
||||
],
|
||||
);
|
||||
|
||||
// 등록/수정 뷰가 스튜디오에서 구성되어 있는지 체크 (있으면 TemplateRenderer로 렌더)
|
||||
const hasCustomCreateView = useMemo(() => {
|
||||
const cv = (template?.views as any)?.create;
|
||||
if (Array.isArray(cv)) return cv.length > 0;
|
||||
return Array.isArray(cv?.components) && cv.components.length > 0;
|
||||
}, [template]);
|
||||
|
||||
const hasCustomEditView = useMemo(() => {
|
||||
const ev = (template?.views as any)?.edit;
|
||||
if (Array.isArray(ev)) return ev.length > 0;
|
||||
return Array.isArray(ev?.components) && ev.components.length > 0;
|
||||
}, [template]);
|
||||
|
||||
return (
|
||||
<div className={`dash-card${isCollapsed ? ' collapsed' : ''}`}>
|
||||
<div className="dash-card-head">
|
||||
@@ -358,77 +383,15 @@ export function DashboardCard({
|
||||
tableName={primaryTable}
|
||||
/>
|
||||
|
||||
<div className="dash-resize-handle" data-resize="true" />
|
||||
|
||||
{formMode && formRow && template && (
|
||||
<FormOverlay
|
||||
title={`${templateName} ${formMode === 'create' ? '등록' : '수정'}`}
|
||||
fields={fields}
|
||||
initialRow={formRow}
|
||||
onSubmit={handleSubmitForm}
|
||||
onClose={closeForm}
|
||||
template={template}
|
||||
view={formMode}
|
||||
hasCustomView={formMode === 'create' ? hasCustomCreateView : hasCustomEditView}
|
||||
context={renderContext}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormOverlayProps {
|
||||
title: string;
|
||||
fields: FieldConfig[];
|
||||
initialRow: Record<string, any>;
|
||||
onSubmit: (row: Record<string, any>) => void;
|
||||
onClose: () => void;
|
||||
template: Template;
|
||||
view: 'create' | 'edit';
|
||||
hasCustomView: boolean;
|
||||
context: TemplateRenderContext;
|
||||
}
|
||||
|
||||
function FormOverlay({
|
||||
title,
|
||||
fields,
|
||||
initialRow,
|
||||
onSubmit,
|
||||
onClose,
|
||||
template,
|
||||
view,
|
||||
hasCustomView,
|
||||
context,
|
||||
}: FormOverlayProps) {
|
||||
return (
|
||||
<div
|
||||
className="dash-form-overlay"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="dash-form-modal">
|
||||
<div className="dash-form-head">
|
||||
<span className="dash-form-title">{title}</span>
|
||||
<button className="dash-card-btn" onClick={onClose} title="닫기">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dash-form-body">
|
||||
{hasCustomView ? (
|
||||
// 스튜디오에서 구성한 등록/수정 뷰 → TemplateRenderer
|
||||
<TemplateRenderer template={template} context={context} view={view} />
|
||||
) : (
|
||||
// 기본 FcForm fallback (스튜디오에서 뷰 구성 안 한 경우)
|
||||
<FcForm
|
||||
fields={fields}
|
||||
loadRow={initialRow}
|
||||
onSubmit={onSubmit}
|
||||
config={{ columns: 2 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 8방향 리사이즈 핸들 — edit mode 에서만 보임 (CSS 제어) */}
|
||||
<div className="dash-resize-handle n" data-resize="n" />
|
||||
<div className="dash-resize-handle s" data-resize="s" />
|
||||
<div className="dash-resize-handle e" data-resize="e" />
|
||||
<div className="dash-resize-handle w" data-resize="w" />
|
||||
<div className="dash-resize-handle ne" data-resize="ne" />
|
||||
<div className="dash-resize-handle nw" data-resize="nw" />
|
||||
<div className="dash-resize-handle se" data-resize="se" />
|
||||
<div className="dash-resize-handle sw" data-resize="sw" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,15 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
|
||||
try {
|
||||
const cardList = await getDashboardCards(dashId);
|
||||
if (useDashboardStore.getState().activeDashboardId === dashId) {
|
||||
setCards(cardList ?? []);
|
||||
const normalized = (cardList ?? []).map((c: Record<string, any>) => ({
|
||||
...c,
|
||||
position_x: c.position_x ?? c.POSITION_X,
|
||||
position_y: c.position_y ?? c.POSITION_Y,
|
||||
width: c.width ?? c.WIDTH,
|
||||
height: c.height ?? c.HEIGHT,
|
||||
is_collapsed: c.is_collapsed ?? c.IS_COLLAPSED ?? false,
|
||||
}));
|
||||
setCards(normalized);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Dashboard] Load cards failed:', err);
|
||||
@@ -153,19 +161,18 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
|
||||
setActiveDashboard(id);
|
||||
};
|
||||
|
||||
// 템플릿 추가 (라이브러리 → 카드) — 화면 정중앙 배치 + 기존 카드 수만큼 stagger
|
||||
// 템플릿 추가 (라이브러리 → 카드) — 화면 중앙 배치 + 기존 카드 수만큼 stagger
|
||||
// 저장 단위: % (0~100). 캔버스 크기 무관하게 반응형으로 동작.
|
||||
const handleSelectTemplate = async (template: Record<string, any>) => {
|
||||
if (!activeDashboardId) return;
|
||||
const templateId = template.template_id ?? template.TEMPLATE_ID;
|
||||
try {
|
||||
const cv = canvasRef.current;
|
||||
const cw = cv?.clientWidth ?? 1000;
|
||||
const ch = cv?.clientHeight ?? 600;
|
||||
const w = Math.min(700, Math.max(320, cw - 40));
|
||||
const h = Math.min(450, Math.max(240, ch - 40));
|
||||
const stagger = (cards.length % 8) * 28;
|
||||
const x = Math.max(16, Math.round((cw - w) / 2) + stagger);
|
||||
const y = Math.max(16, Math.round((ch - h) / 2) + stagger);
|
||||
// 기본 크기: 너비 45%, 높이 50%. stagger: 3% 씩 오프셋 (최대 8장 순환)
|
||||
const w = 45;
|
||||
const h = 50;
|
||||
const stagger = (cards.length % 8) * 3;
|
||||
const x = Math.max(2, Math.min(100 - w - 2, Math.round((100 - w) / 2) + stagger));
|
||||
const y = Math.max(2, Math.min(100 - h - 2, Math.round((100 - h) / 2) + stagger));
|
||||
|
||||
const result = await insertDashboardCard(activeDashboardId, {
|
||||
template_id: templateId,
|
||||
@@ -255,7 +262,7 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
|
||||
<>
|
||||
{/* 편집/제어 툴바는 이제 헤더로 hoist. 캔버스 FAB 이 모드 내 액션 담당. */}
|
||||
{/* ★ flex container로 만들어야 안쪽 dash-canvas가 flex:1로 늘어남 */}
|
||||
<div style={{ position: 'relative', flex: '1 1 auto', display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0 }}>
|
||||
<div style={{ position: 'relative', flex: '1 1 auto', display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0, width: '100%', height: '100%' }}>
|
||||
<DashboardCanvas
|
||||
ref={canvasRef}
|
||||
dashboardName={dashName}
|
||||
|
||||
@@ -44,10 +44,13 @@ export interface TemplateRenderContext {
|
||||
fields: FieldConfig[];
|
||||
data: Record<string, any>[];
|
||||
loading: boolean;
|
||||
primaryTable?: string;
|
||||
selectedRow: Record<string, any> | null;
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
/** 현재 검색 파라미터 — Search 컴포넌트가 onSearch 로 업데이트하면 Table 로 전달 */
|
||||
searchParams: Record<string, any>;
|
||||
onSearch: (params: Record<string, any>) => void;
|
||||
onRowSelect: (row: Record<string, any>) => void;
|
||||
onPageChange: (args: { page: number; size: number }) => void;
|
||||
@@ -170,6 +173,7 @@ function LineGridView({
|
||||
computeLayout(
|
||||
blocks.map((b) => ({
|
||||
id: b.id,
|
||||
componentId: b.componentId,
|
||||
xPct: b.xPct,
|
||||
yPct: b.yPct,
|
||||
wPct: b.wPct,
|
||||
@@ -310,6 +314,8 @@ function LineGridView({
|
||||
deficit: delta < 0 ? Math.round(-delta) : 0,
|
||||
surplus: delta > 0 ? Math.round(delta) : 0,
|
||||
template: gridTemplateRows,
|
||||
xLines: layout.xLines.map((v) => +v.toFixed(4)),
|
||||
yLines: layout.yLines.map((v) => +v.toFixed(4)),
|
||||
},
|
||||
);
|
||||
console.table(
|
||||
@@ -344,6 +350,22 @@ function LineGridView({
|
||||
'color:#00cec9;font-weight:bold',
|
||||
`engine=line view=${view} blocks=${blocks.length} grid=${gridCount} overlay=${overlayCount} cols=${layout.cols.length} rows=${layout.rows.length} tolX=${layout.xToleranceUsed.toFixed(4)} tolY=${layout.yToleranceUsed.toFixed(4)} canvas=${canvas.baseWidth}x${canvas.baseHeight}`,
|
||||
);
|
||||
console.table(
|
||||
layout.cols.map((c) => ({
|
||||
col: c.index + 1,
|
||||
start: +c.startPct.toFixed(4),
|
||||
end: +c.endPct.toFixed(4),
|
||||
weight: +c.weight.toFixed(4),
|
||||
})),
|
||||
);
|
||||
console.table(
|
||||
layout.rows.map((r) => ({
|
||||
row: r.index + 1,
|
||||
start: +r.startPct.toFixed(4),
|
||||
end: +r.endPct.toFixed(4),
|
||||
weight: +r.weight.toFixed(4),
|
||||
})),
|
||||
);
|
||||
console.table(
|
||||
layout.blocks.map((bl) => {
|
||||
const b = byId.get(bl.blockId);
|
||||
@@ -352,6 +374,8 @@ function LineGridView({
|
||||
cid: b?.componentId ?? '',
|
||||
role: b?.role ?? '',
|
||||
policy: b?.responsivePolicy ?? '',
|
||||
xPct: b ? +b.xPct.toFixed(4) : '',
|
||||
yPct: b ? +b.yPct.toFixed(4) : '',
|
||||
wPct: b ? +b.wPct.toFixed(3) : '',
|
||||
hPct: b ? +b.hPct.toFixed(3) : '',
|
||||
px_w: b ? Math.round(b.wPct * canvas.baseWidth) : '',
|
||||
@@ -543,6 +567,21 @@ const LINE_CSS = `
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* search — 가로는 cell 폭을 채우되, 세로는 content-size 유지.
|
||||
row 가 조금 커져도 검색줄 전체가 100% 높이로 늘어나지 않게 한다. */
|
||||
.itpl-line .itpl-slot[data-comp="search"] {
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.itpl-line .itpl-slot[data-comp="search"] > * {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* compact: cell final 높이가 preferred 대비 10% 이상 축소된 경우 부여.
|
||||
폰트/padding 을 소폭 줄여 잘림을 완화. outer scroll 은 만들지 않는다. */
|
||||
.itpl-line .itpl-slot.compact,
|
||||
@@ -597,6 +636,11 @@ function BlockRenderer({
|
||||
height: number;
|
||||
};
|
||||
}) {
|
||||
const resolvedTableName =
|
||||
block.config?.selectedTable ||
|
||||
block.config?.tableName ||
|
||||
context.primaryTable;
|
||||
|
||||
const def = ComponentRegistry.getComponent(block.componentId);
|
||||
if (!def?.component) {
|
||||
return (
|
||||
@@ -646,6 +690,7 @@ function BlockRenderer({
|
||||
component={{
|
||||
id: block.id,
|
||||
componentType: block.componentId,
|
||||
tableName: resolvedTableName,
|
||||
position,
|
||||
size: effectiveSize,
|
||||
componentConfig: block.config,
|
||||
@@ -654,12 +699,21 @@ function BlockRenderer({
|
||||
}}
|
||||
componentConfig={block.config}
|
||||
config={block.config}
|
||||
tableName={resolvedTableName}
|
||||
isDesignMode={false}
|
||||
isPreview={true}
|
||||
formData={context.formRow}
|
||||
onFormDataChange={(fieldName: string, value: any) =>
|
||||
context.onFormRowChange?.({ [fieldName]: value })
|
||||
}
|
||||
onSearch={context.onSearch}
|
||||
searchParams={context.searchParams}
|
||||
onAdd={context.onAdd}
|
||||
onEdit={context.onEdit}
|
||||
onDelete={context.onDelete}
|
||||
onFormSubmit={context.onFormSubmit}
|
||||
onFormCancel={context.onFormCancel}
|
||||
selectedRow={context.selectedRow}
|
||||
view={view}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ const DEFAULT_CONTEXT: TemplateRenderContext = {
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
searchParams: {},
|
||||
onSearch: () => {},
|
||||
onRowSelect: () => {},
|
||||
onPageChange: () => {},
|
||||
|
||||
@@ -582,7 +582,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||
delete: "삭제",
|
||||
edit: "수정",
|
||||
copy: "복사",
|
||||
add: "추가",
|
||||
add: "등록",
|
||||
search: "검색",
|
||||
reset: "초기화",
|
||||
submit: "제출",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,15 +23,30 @@ interface ViewTabBarProps {
|
||||
onViewChange: (view: ViewType) => void;
|
||||
/** 각 뷰에 컴포넌트가 있는지 (뱃지 표시용) */
|
||||
viewCounts?: Record<ViewType, number>;
|
||||
/**
|
||||
* 표시할 뷰 탭. 생략 시 전체 표시.
|
||||
* list 는 항상 포함 (루트). create/edit 는 목록 뷰에
|
||||
* 해당 actionType 버튼이 배치됐을 때만 포함되는 식으로
|
||||
* 부모에서 결정한다.
|
||||
*/
|
||||
visibleViews?: ViewType[];
|
||||
}
|
||||
|
||||
export function ViewTabBar({ activeView, onViewChange, viewCounts }: ViewTabBarProps) {
|
||||
const activeTab = VIEW_TABS.find((t) => t.id === activeView);
|
||||
export function ViewTabBar({
|
||||
activeView,
|
||||
onViewChange,
|
||||
viewCounts,
|
||||
visibleViews,
|
||||
}: ViewTabBarProps) {
|
||||
const tabs = visibleViews
|
||||
? VIEW_TABS.filter((t) => visibleViews.includes(t.id))
|
||||
: VIEW_TABS;
|
||||
const activeTab = tabs.find((t) => t.id === activeView);
|
||||
|
||||
return (
|
||||
<div className="view-tab-bar">
|
||||
<div className="view-tab-group">
|
||||
{VIEW_TABS.map((tab) => {
|
||||
{tabs.map((tab) => {
|
||||
const count = viewCounts?.[tab.id] ?? 0;
|
||||
const isActive = tab.id === activeView;
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
|
||||
delete: "삭제",
|
||||
edit: "수정",
|
||||
copy: "복사",
|
||||
add: "추가",
|
||||
add: "등록",
|
||||
search: "검색",
|
||||
reset: "초기화",
|
||||
submit: "제출",
|
||||
|
||||
@@ -244,7 +244,12 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
extraProps.inputType = dbInputType;
|
||||
extraProps.tableName = resolvedTableName;
|
||||
extraProps.columnName = resolvedColumnName;
|
||||
extraProps.screenTableName = resolvedTableName;
|
||||
extraProps.screenTableName = currentTableName || resolvedTableName;
|
||||
extraProps.tables = allTables.map((table: any) => ({
|
||||
tableName: table.tableName || table.table_name,
|
||||
displayName: table.displayName || table.display_name || table.table_comment,
|
||||
tableComment: table.tableComment || table.table_comment,
|
||||
}));
|
||||
}
|
||||
if (componentId === "v2-input") {
|
||||
extraProps.allComponents = allComponents;
|
||||
|
||||
@@ -76,6 +76,12 @@ interface SlimToolbarProps {
|
||||
activeView?: ViewType;
|
||||
onViewChange?: (view: ViewType) => void;
|
||||
viewCounts?: Record<ViewType, number>;
|
||||
/**
|
||||
* 표시할 뷰 탭. 생략 시 전체 표시.
|
||||
* list 는 루트로 항상 포함. create/edit 는 목록 뷰에 해당
|
||||
* actionType(add/edit) 버튼이 배치됐을 때만 부모가 포함시킨다.
|
||||
*/
|
||||
visibleViews?: ViewType[];
|
||||
}
|
||||
|
||||
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||
@@ -100,7 +106,11 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||
activeView = "list",
|
||||
onViewChange,
|
||||
viewCounts,
|
||||
visibleViews,
|
||||
}) => {
|
||||
const tabs = visibleViews
|
||||
? VIEW_TABS.filter((t) => visibleViews.includes(t.id))
|
||||
: VIEW_TABS;
|
||||
return (
|
||||
<div className="slim-toolbar hdr">
|
||||
{/* Zone 1: Brand */}
|
||||
@@ -133,7 +143,7 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||
{/* Zone 3: View tabs inline */}
|
||||
<div className="hdr-zone" style={{ padding: 0 }}>
|
||||
<div className="tab-group">
|
||||
{VIEW_TABS.map((tab) => {
|
||||
{tabs.map((tab) => {
|
||||
const count = viewCounts?.[tab.id] ?? 0;
|
||||
const isActive = tab.id === activeView;
|
||||
return (
|
||||
|
||||
@@ -67,6 +67,12 @@ const USER_FIELD_OPTIONS = [
|
||||
{ value: "userName", label: "사용자명" },
|
||||
] as const;
|
||||
|
||||
const DATA_ROLE_OPTIONS = [
|
||||
{ value: "primary", label: "주 테이블", desc: "이 화면의 메인 데이터예요" },
|
||||
{ value: "reference", label: "참조 테이블", desc: "다른 테이블 값을 읽어와요" },
|
||||
{ value: "child", label: "하위 테이블", desc: "상세/매핑 같은 하위 데이터예요" },
|
||||
] as const;
|
||||
|
||||
interface ColumnOption {
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
@@ -220,15 +226,21 @@ export const V2FieldConfigPanel: React.FC<V2FieldConfigPanelProps> = ({
|
||||
}) => {
|
||||
const fieldType = resolveFieldType(config, componentType, metaInputType);
|
||||
const isSelectGroup = ["select", "category", "entity"].includes(fieldType);
|
||||
const primaryTableName = screenTableName || tableName || "";
|
||||
const dataRole = (config.dataRole || "primary") as "primary" | "reference" | "child";
|
||||
const selectedSourceTable = dataRole === "primary" ? primaryTableName : config.sourceTable || "";
|
||||
const hasReferenceSource = dataRole !== "primary";
|
||||
|
||||
// ─── 채번 관련 상태 (테이블 기반) ───
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [loadingRules, setLoadingRules] = useState(false);
|
||||
const numberingTableName = screenTableName || tableName;
|
||||
const numberingTableName = primaryTableName;
|
||||
|
||||
// ─── 셀렉트 관련 상태 ───
|
||||
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [sourceColumns, setSourceColumns] = useState<ColumnOption[]>([]);
|
||||
const [loadingSourceColumns, setLoadingSourceColumns] = useState(false);
|
||||
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
|
||||
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
|
||||
const [filterColumns, setFilterColumns] = useState<ColumnOption[]>([]);
|
||||
@@ -240,6 +252,26 @@ export const V2FieldConfigPanel: React.FC<V2FieldConfigPanelProps> = ({
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
const loadColumnsForTable = useCallback(async (tblName: string): Promise<ColumnOption[]> => {
|
||||
if (!tblName) return [];
|
||||
|
||||
const resp = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`);
|
||||
const data = resp.data.data || resp.data;
|
||||
const cols = data.columns || data || [];
|
||||
|
||||
return cols.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name || col.name,
|
||||
columnLabel:
|
||||
col.displayName ||
|
||||
col.display_name ||
|
||||
col.columnLabel ||
|
||||
col.column_label ||
|
||||
col.columnName ||
|
||||
col.column_name ||
|
||||
col.name,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ─── 필드 타입 전환 핸들러 ───
|
||||
const handleFieldTypeChange = (newType: FieldType) => {
|
||||
const newIsSelect = ["select", "category", "entity"].includes(newType);
|
||||
@@ -278,6 +310,20 @@ export const V2FieldConfigPanel: React.FC<V2FieldConfigPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataRoleChange = (nextRole: "primary" | "reference" | "child") => {
|
||||
const nextConfig: Record<string, any> = { ...config, dataRole: nextRole };
|
||||
|
||||
if (nextRole === "primary") {
|
||||
nextConfig.sourceTable = primaryTableName || "";
|
||||
nextConfig.sourceColumn = config.sourceColumn || columnName || config.columnName || "";
|
||||
} else {
|
||||
nextConfig.sourceTable = config.sourceTable || "";
|
||||
nextConfig.sourceColumn = config.sourceColumn || "";
|
||||
}
|
||||
|
||||
onChange(nextConfig);
|
||||
};
|
||||
|
||||
// ─── 채번 규칙 로드 (테이블 기반) ───
|
||||
useEffect(() => {
|
||||
if (fieldType !== "numbering") return;
|
||||
@@ -298,20 +344,34 @@ export const V2FieldConfigPanel: React.FC<V2FieldConfigPanelProps> = ({
|
||||
if (!tblName) { setEntityColumns([]); return; }
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const resp = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`);
|
||||
const data = resp.data.data || resp.data;
|
||||
const cols = data.columns || data || [];
|
||||
setEntityColumns(cols.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name || col.name,
|
||||
columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
|
||||
})));
|
||||
setEntityColumns(await loadColumnsForTable(tblName));
|
||||
} catch { setEntityColumns([]); } finally { setLoadingColumns(false); }
|
||||
}, []);
|
||||
}, [loadColumnsForTable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fieldType === "entity" && config.entityTable) loadEntityColumns(config.entityTable);
|
||||
}, [fieldType, config.entityTable, loadEntityColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSourceTable) {
|
||||
setSourceColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
setLoadingSourceColumns(true);
|
||||
try {
|
||||
setSourceColumns(await loadColumnsForTable(selectedSourceTable));
|
||||
} catch {
|
||||
setSourceColumns([]);
|
||||
} finally {
|
||||
setLoadingSourceColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
}, [selectedSourceTable, loadColumnsForTable]);
|
||||
|
||||
// ─── 카테고리 값 로드 ───
|
||||
const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => {
|
||||
if (!catTable || !catColumn) { setCategoryValues([]); return; }
|
||||
@@ -352,17 +412,11 @@ export const V2FieldConfigPanel: React.FC<V2FieldConfigPanelProps> = ({
|
||||
const load = async () => {
|
||||
setLoadingFilterColumns(true);
|
||||
try {
|
||||
const resp = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`);
|
||||
const data = resp.data.data || resp.data;
|
||||
const cols = data.columns || data || [];
|
||||
setFilterColumns(cols.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name || col.name,
|
||||
columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
|
||||
})));
|
||||
setFilterColumns(await loadColumnsForTable(filterTargetTable));
|
||||
} catch { setFilterColumns([]); } finally { setLoadingFilterColumns(false); }
|
||||
};
|
||||
load();
|
||||
}, [filterTargetTable]);
|
||||
}, [filterTargetTable, loadColumnsForTable]);
|
||||
|
||||
// ─── 옵션 관리 (select static) ───
|
||||
const options = config.options || [];
|
||||
@@ -407,6 +461,106 @@ export const V2FieldConfigPanel: React.FC<V2FieldConfigPanelProps> = ({
|
||||
</div>
|
||||
|
||||
{/* ═══ 2단계: 유형별 상세 설정 ═══ */}
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">데이터 소속</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{DATA_ROLE_OPTIONS.map((option) => {
|
||||
const isSelected = dataRole === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleDataRoleChange(option.value)}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||
: "border-border hover:border-primary/40 hover:bg-background/60",
|
||||
)}
|
||||
>
|
||||
<div className={cn("text-xs font-medium", isSelected ? "text-primary" : "text-foreground")}>
|
||||
{option.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] leading-tight text-muted-foreground">{option.desc}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-background p-3 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">데이터 테이블</p>
|
||||
{hasReferenceSource ? (
|
||||
<Select
|
||||
value={config.sourceTable || ""}
|
||||
onValueChange={(value) => {
|
||||
onChange({ ...config, dataRole, sourceTable: value, sourceColumn: "" });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="참조할 테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.tableName} value={t.tableName}>
|
||||
{t.displayName || t.tableComment ? `${t.displayName || t.tableComment} (${t.tableName})` : t.tableName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
|
||||
{primaryTableName || "먼저 화면 메인 테이블을 연결해주세요"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">데이터 컬럼</p>
|
||||
{hasReferenceSource ? (
|
||||
<Select
|
||||
value={config.sourceColumn || ""}
|
||||
onValueChange={(value) => updateConfig("sourceColumn", value)}
|
||||
disabled={!selectedSourceTable || loadingSourceColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder={!selectedSourceTable ? "먼저 테이블 선택" : "컬럼 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceColumns.map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
{column.columnLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
|
||||
{columnName || config.columnName || config.fieldKey || "-"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasReferenceSource && loadingSourceColumns && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
참조 컬럼 목록 로딩 중...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasReferenceSource && selectedSourceTable && !loadingSourceColumns && sourceColumns.length === 0 && (
|
||||
<p className="text-[10px] text-amber-600">선택한 테이블의 컬럼 정보를 불러올 수 없어요.</p>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
주 테이블은 현재 화면의 메인 데이터고, 참조/하위는 다른 테이블 값을 직접 연결할 때 써요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 텍스트/숫자/여러줄: 기본 설정 ─── */}
|
||||
{(fieldType === "text" || fieldType === "number" || fieldType === "textarea") && (
|
||||
|
||||
@@ -274,9 +274,11 @@ apiClient.interceptors.request.use(
|
||||
if (newToken) {
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
} else {
|
||||
// 갱신 실패 시 인증 없는 요청을 보내면 TOKEN_MISSING 401 → 즉시 redirectToLogin 연쇄 장애
|
||||
// 요청 자체를 차단하여 호출부의 try/catch에서 처리하도록 함
|
||||
authLog("TOKEN_REFRESH_FAIL", `요청 인터셉터에서 갱신 실패 → 요청 차단 (${config.url})`);
|
||||
// 갱신 실패 = 세션 복구 불가. 로그인 페이지로 즉시 이동해야 호출부가
|
||||
// 무한 로딩에 빠지지 않음. redirectToLogin 은 isRedirecting 플래그로
|
||||
// 중복 호출을 막으므로 동시 요청이 여러 건이어도 안전함.
|
||||
authLog("TOKEN_REFRESH_FAIL", `요청 인터셉터에서 갱신 실패 → 로그인 리다이렉트 (${config.url})`);
|
||||
redirectToLogin();
|
||||
return Promise.reject(new Error("TOKEN_REFRESH_FAILED"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,27 +174,35 @@ export const dashboardApi = {
|
||||
|
||||
/**
|
||||
* 내 대시보드 목록 조회
|
||||
*
|
||||
* 백엔드 DashboardController: GET /api/dashboards
|
||||
* - user_id/company_code 는 request attribute 에서 자동 주입되므로 별도 파라미터 불필요
|
||||
* - 응답 구조: { success, data: { list:[], total_count, limit, page, total_page } }
|
||||
*/
|
||||
async getMyDashboards(query: DashboardListQuery = {}) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (query.page) params.append("page", query.page.toString());
|
||||
if (query.limit) params.append("limit", query.limit.toString());
|
||||
if (query.search) params.append("search", query.search);
|
||||
if (query.category) params.append("category", query.category);
|
||||
if (query.search) params.append("keyword", query.search);
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = `/dashboard/my${queryString ? `?${queryString}` : ""}`;
|
||||
const endpoint = `/dashboards${queryString ? `?${queryString}` : ""}`;
|
||||
|
||||
const result = await apiRequest<{ dashboards: Dashboard[] }>(endpoint);
|
||||
const result = await apiRequest<{ list: Dashboard[]; total_count?: number; total_page?: number; page?: number; limit?: number }>(endpoint);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "내 대시보드 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
dashboards: result.data?.dashboards || [],
|
||||
pagination: result.pagination,
|
||||
dashboards: result.data?.list || [],
|
||||
pagination: {
|
||||
total: result.data?.total_count,
|
||||
totalPage: result.data?.total_page,
|
||||
page: result.data?.page,
|
||||
limit: result.data?.limit,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ export const LAYOUT_CONSTANTS = {
|
||||
OVERLAP_THRESHOLD: 0.15,
|
||||
/** snap 오차 > tolerance * 이 값 이면 overlay */
|
||||
SNAP_ERROR_MULT: 2,
|
||||
/** 같은 윗선(row band)으로 볼 bottom 차이 허용치 */
|
||||
ROW_BAND_TOLERANCE: 0.012,
|
||||
/** row band 자동 정렬 대상으로 보는 최대 높이 비율 */
|
||||
ROW_LIKE_MAX_HEIGHT: 0.12,
|
||||
} as const;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -43,6 +47,7 @@ export const LAYOUT_CONSTANTS = {
|
||||
|
||||
export interface LayoutBlockInput {
|
||||
id: string;
|
||||
componentId?: string;
|
||||
xPct: number;
|
||||
yPct: number;
|
||||
wPct: number;
|
||||
@@ -63,6 +68,8 @@ export interface LayoutOptions {
|
||||
snapErrorMult?: number;
|
||||
/** 세로 축 사이징 — 'fr' 는 비율 grid, 'auto' 는 content-size */
|
||||
rowSizing?: 'fr' | 'auto';
|
||||
/** 한 줄 control 은 세로 span 을 1행으로 축소할 componentId 목록 */
|
||||
intrinsicRowSpanIds?: string[];
|
||||
}
|
||||
|
||||
export interface GridTrack {
|
||||
@@ -321,9 +328,13 @@ export function computeLayout(
|
||||
const thinTh = opts.thinTrackThreshold ?? LAYOUT_CONSTANTS.THIN_TRACK_THRESHOLD;
|
||||
const overlapTh = opts.overlapThreshold ?? LAYOUT_CONSTANTS.OVERLAP_THRESHOLD;
|
||||
const snapMult = opts.snapErrorMult ?? LAYOUT_CONSTANTS.SNAP_ERROR_MULT;
|
||||
const intrinsicRowSpanIds = new Set(
|
||||
opts.intrinsicRowSpanIds ?? ['search', 'button', 'button-bar', 'pagination'],
|
||||
);
|
||||
const normalizedBlocks = normalizeRowBands(blocks);
|
||||
|
||||
// 0. 빈 입력 처리
|
||||
if (!Array.isArray(blocks) || blocks.length === 0) {
|
||||
if (!Array.isArray(normalizedBlocks) || normalizedBlocks.length === 0) {
|
||||
return {
|
||||
xLines: [0, 1],
|
||||
yLines: [0, 1],
|
||||
@@ -338,7 +349,7 @@ export function computeLayout(
|
||||
// 1. forced overlay 분리 (grid 선 추출에서 제외)
|
||||
const candidates: LayoutBlockInput[] = [];
|
||||
const forced: LayoutBlockInput[] = [];
|
||||
for (const b of blocks) {
|
||||
for (const b of normalizedBlocks) {
|
||||
if (b.forceOverlay) forced.push(b);
|
||||
else candidates.push(b);
|
||||
}
|
||||
@@ -366,11 +377,11 @@ export function computeLayout(
|
||||
// 4. 입력 순서를 그대로 유지하며 span 매핑
|
||||
// grid 확정된 블록은 gridOk 에 따로 쌓아두고, overlap 판정에 사용.
|
||||
const byId = new Map<string, BlockLayout>();
|
||||
const ordered: BlockLayout[] = new Array(blocks.length);
|
||||
const ordered: BlockLayout[] = new Array(normalizedBlocks.length);
|
||||
const gridOk: { input: LayoutBlockInput; layout: BlockLayout; inputIdx: number }[] = [];
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const b = blocks[i];
|
||||
for (let i = 0; i < normalizedBlocks.length; i++) {
|
||||
const b = normalizedBlocks[i];
|
||||
|
||||
if (b.forceOverlay) {
|
||||
const layout = makeOverlay(b, 0, 'forced');
|
||||
@@ -394,8 +405,20 @@ export function computeLayout(
|
||||
|
||||
const colStart = l.idx + 1;
|
||||
const colEnd = r.idx + 1;
|
||||
const rowStart = t.idx + 1;
|
||||
const rowEnd = bt.idx + 1;
|
||||
let rowStart = t.idx + 1;
|
||||
let rowEnd = bt.idx + 1;
|
||||
|
||||
// 한 줄 control 은 저장된 큰 bounding box 때문에 여러 row 를 점유해도,
|
||||
// 실제 렌더는 한 줄 높이로 보이는 경우가 많다. 이런 컴포넌트는 centerY
|
||||
// 기준 단일 row span 으로 축소해 중간 공백 row 가 커지는 문제를 막는다.
|
||||
const componentId = b.componentId;
|
||||
if (componentId && intrinsicRowSpanIds.has(componentId) && rowEnd > rowStart + 1) {
|
||||
const centerY = b.yPct + b.hPct / 2;
|
||||
let centerIdx = nearestLine(centerY, yLines).idx;
|
||||
centerIdx = Math.max(0, Math.min(centerIdx, yLines.length - 2));
|
||||
rowStart = centerIdx + 1;
|
||||
rowEnd = rowStart + 1;
|
||||
}
|
||||
|
||||
if (colEnd <= colStart || rowEnd <= rowStart) {
|
||||
const layout = makeOverlay(b, snapError, 'invalid-span');
|
||||
@@ -447,6 +470,44 @@ export function computeLayout(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRowBands(blocks: LayoutBlockInput[]): LayoutBlockInput[] {
|
||||
const tol = LAYOUT_CONSTANTS.ROW_BAND_TOLERANCE;
|
||||
const maxHeight = LAYOUT_CONSTANTS.ROW_LIKE_MAX_HEIGHT;
|
||||
const cloned = blocks.map((b) => ({ ...b }));
|
||||
const rowLikes = cloned
|
||||
.filter((b) => !b.forceOverlay && b.hPct <= maxHeight)
|
||||
.sort((a, b) => a.yPct - b.yPct || a.xPct - b.xPct);
|
||||
|
||||
let i = 0;
|
||||
while (i < rowLikes.length) {
|
||||
const seedTop = rowLikes[i].yPct;
|
||||
const band: LayoutBlockInput[] = [rowLikes[i]];
|
||||
let j = i + 1;
|
||||
while (j < rowLikes.length && Math.abs(rowLikes[j].yPct - seedTop) <= tol) {
|
||||
band.push(rowLikes[j]);
|
||||
j++;
|
||||
}
|
||||
|
||||
if (band.length >= 2) {
|
||||
const bottoms = band.map((b) => b.yPct + b.hPct);
|
||||
const minBottom = Math.min(...bottoms);
|
||||
const maxBottom = Math.max(...bottoms);
|
||||
|
||||
// 같은 윗선에서 시작한 작은 높이 블록은 bottom 차이가 조금 나도
|
||||
// 하나의 row band 로 본다. 더 긴 블록 쪽으로 맞춰 새 가로선 분리를 막는다.
|
||||
if (maxBottom - minBottom <= tol) {
|
||||
for (const block of band) {
|
||||
block.hPct = Math.max(0, maxBottom - block.yPct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
i = j;
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 내부 유틸
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -485,7 +546,7 @@ export interface RowClassifyOptions {
|
||||
rigidRoles?: string[];
|
||||
/** rigid 판정 componentId (기본 ['button','button-bar','pagination']) */
|
||||
rigidComponentIds?: string[];
|
||||
/** elastic 판정 componentId (기본 table/container/form/search/tabs/split-panel/accordion) */
|
||||
/** elastic 판정 componentId (기본 table/container/form/tabs/split-panel/accordion) */
|
||||
elasticComponentIds?: string[];
|
||||
/** floor (px) */
|
||||
rigidFloor?: number; // default 40
|
||||
@@ -493,6 +554,10 @@ export interface RowClassifyOptions {
|
||||
elasticFloor?: number; // default 80
|
||||
/** gap row 의 floor (px). 기본 0 (거의 0 까지 줄일 수 있다). */
|
||||
gapFloor?: number;
|
||||
/** gap row 의 preferred 상한 (px). 큰 공백 행이 카드 내부를 과도하게 먹지 않게 한다. */
|
||||
gapPreferredCap?: number;
|
||||
/** 단일 제어 컴포넌트의 preferred share 상한 (px) */
|
||||
intrinsicPreferredCaps?: Partial<Record<string, number>>;
|
||||
}
|
||||
|
||||
export interface RowPlan {
|
||||
@@ -528,7 +593,6 @@ export function classifyRows(
|
||||
'table',
|
||||
'container',
|
||||
'form',
|
||||
'search',
|
||||
'tabs',
|
||||
'split-panel',
|
||||
'accordion',
|
||||
@@ -538,6 +602,14 @@ export function classifyRows(
|
||||
const semiFloor = opts.semiFloor ?? 56;
|
||||
const elasticFloor = opts.elasticFloor ?? 80;
|
||||
const gapFloor = opts.gapFloor ?? 0;
|
||||
const gapPreferredCap = opts.gapPreferredCap ?? 24;
|
||||
const intrinsicPreferredCaps = {
|
||||
search: 44,
|
||||
button: 40,
|
||||
'button-bar': 40,
|
||||
pagination: 40,
|
||||
...(opts.intrinsicPreferredCaps ?? {}),
|
||||
};
|
||||
|
||||
const byId = new Map<string, RowClassifyBlockInfo>();
|
||||
for (const b of blocks) byId.set(b.id, b);
|
||||
@@ -555,7 +627,7 @@ export function classifyRows(
|
||||
}
|
||||
|
||||
for (const bl of layout.blocks) {
|
||||
if (bl.mode !== 'grid') continue;
|
||||
if (bl.mode === 'overlay') continue;
|
||||
const b = byId.get(bl.blockId);
|
||||
if (!b) continue;
|
||||
const rs = (bl.rowStart ?? 1) - 1;
|
||||
@@ -563,7 +635,12 @@ export function classifyRows(
|
||||
const span = re - rs;
|
||||
if (span <= 0) continue;
|
||||
const designH = Math.max(0, b.hPct * baseHeight);
|
||||
const share = designH / span;
|
||||
const intrinsicCap =
|
||||
b.componentId != null ? intrinsicPreferredCaps[b.componentId] : undefined;
|
||||
const share =
|
||||
intrinsicCap != null
|
||||
? Math.min(designH / span, intrinsicCap)
|
||||
: designH / span;
|
||||
|
||||
let kind: BlockKind;
|
||||
if (b.componentId && elasticIds.has(b.componentId)) kind = 'elastic';
|
||||
@@ -598,7 +675,10 @@ export function classifyRows(
|
||||
// gap 의 preferred 는 디자인 원본 yLines 간격 (= weight * baseHeight).
|
||||
// 블록이 없으니 maxShare 는 0 이다.
|
||||
const designH = Math.max(0, layout.rows[i].weight * baseHeight);
|
||||
preferredPx = Math.max(floorPx, Math.round(designH));
|
||||
preferredPx = Math.max(
|
||||
floorPx,
|
||||
Math.min(Math.round(designH), gapPreferredCap),
|
||||
);
|
||||
} else {
|
||||
floorPx =
|
||||
type === 'rigid'
|
||||
|
||||
@@ -517,7 +517,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||
// DB input_type이 "text" 등 비-카테고리로 변경된 경우 이 분기를 건너뜀
|
||||
const savedInputType = (component as any).componentConfig?.inputType || (component as any).inputType;
|
||||
const webType = (component as any).componentConfig?.webType;
|
||||
const tableName = (component as any).tableName;
|
||||
const tableName =
|
||||
(component as any).tableName ||
|
||||
(component as any).componentConfig?.tableName ||
|
||||
props.tableName;
|
||||
const columnName = (component as any).columnName;
|
||||
|
||||
// DB input_type 확인: 데이터타입관리에서 변경한 최신 값이 레이아웃 저장값보다 우선
|
||||
@@ -980,7 +983,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용
|
||||
// 🆕 component.tableName도 확인 (V2 레이아웃에서 overrides.tableName이 복원됨)
|
||||
tableName: useConfigTableName
|
||||
? component.component_config?.tableName || (component as any).tableName || tableName
|
||||
? component.component_config?.tableName ||
|
||||
(component as any).componentConfig?.tableName ||
|
||||
(component as any).tableName ||
|
||||
props.tableName
|
||||
: tableName,
|
||||
menuId, // 🆕 메뉴 ID
|
||||
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
|
||||
|
||||
@@ -182,6 +182,19 @@ export const ButtonComponent: React.FC<ButtonComponentProps> = ({
|
||||
buttonStyle.outlineOffset = "2px";
|
||||
}
|
||||
|
||||
// 템플릿 컨텍스트가 내려주는 액션 콜백 — actionType 에 따라 해당 콜백 호출
|
||||
const p2 = props as any;
|
||||
const ctxOnAdd: (() => void) | undefined =
|
||||
typeof p2.onAdd === "function" ? p2.onAdd : undefined;
|
||||
const ctxOnEdit: (() => void) | undefined =
|
||||
typeof p2.onEdit === "function" ? p2.onEdit : undefined;
|
||||
const ctxOnDelete: (() => void) | undefined =
|
||||
typeof p2.onDelete === "function" ? p2.onDelete : undefined;
|
||||
const ctxOnFormSubmit: (() => void) | undefined =
|
||||
typeof p2.onFormSubmit === "function" ? p2.onFormSubmit : undefined;
|
||||
const ctxOnFormCancel: (() => void) | undefined =
|
||||
typeof p2.onFormCancel === "function" ? p2.onFormCancel : undefined;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
@@ -192,6 +205,32 @@ export const ButtonComponent: React.FC<ButtonComponentProps> = ({
|
||||
if (componentConfig.confirm && !window.confirm(componentConfig.confirm)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// actionType 기반 분기 — 매칭되는 게 있으면 그걸 호출하고,
|
||||
// 아니면 기존 onClick 폴백 (빌더/커스텀 경로)
|
||||
const at = componentConfig.actionType;
|
||||
switch (at) {
|
||||
case "add":
|
||||
if (ctxOnAdd) return ctxOnAdd();
|
||||
break;
|
||||
case "edit":
|
||||
if (ctxOnEdit) return ctxOnEdit();
|
||||
break;
|
||||
case "delete":
|
||||
if (ctxOnDelete) return ctxOnDelete();
|
||||
break;
|
||||
case "save":
|
||||
case "submit":
|
||||
if (ctxOnFormSubmit) return ctxOnFormSubmit();
|
||||
break;
|
||||
case "cancel":
|
||||
case "close":
|
||||
if (ctxOnFormCancel) return ctxOnFormCancel();
|
||||
break;
|
||||
// search / reset / navigate / popup / approval / custom 은 다음 단계
|
||||
default:
|
||||
break;
|
||||
}
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
@@ -262,6 +301,16 @@ export const ButtonComponent: React.FC<ButtonComponentProps> = ({
|
||||
displayMode: _62,
|
||||
iconTextPosition: _63,
|
||||
iconGap: _64,
|
||||
// 템플릿 컨텍스트 액션 콜백 — DOM 에 흘리지 않음
|
||||
onAdd: _65,
|
||||
onEdit: _66,
|
||||
onDelete: _67,
|
||||
onFormSubmit: _68,
|
||||
onFormCancel: _69,
|
||||
onSearch: _70,
|
||||
searchParams: _71,
|
||||
selectedRow: _72,
|
||||
view: _73,
|
||||
...domProps
|
||||
} = props as any;
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
|
||||
@@ -98,7 +98,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||
<option value="save">저장 (save)</option>
|
||||
<option value="edit">수정 (edit)</option>
|
||||
<option value="delete">삭제 (delete)</option>
|
||||
<option value="add">추가 (add)</option>
|
||||
<option value="add">등록 (add)</option>
|
||||
<option value="cancel">취소 (cancel)</option>
|
||||
<option value="close">닫기 (close)</option>
|
||||
<option value="navigate">이동 (navigate)</option>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { SearchConfig } from "./types";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
@@ -58,24 +58,46 @@ export const SearchComponent: React.FC<SearchComponentProps> = ({
|
||||
const placeholder = componentConfig.placeholder ?? "검색어를 입력하세요";
|
||||
const layout = componentConfig.layout ?? "inline";
|
||||
const showResetButton = componentConfig.showResetButton ?? true;
|
||||
const autoSearch = componentConfig.autoSearch ?? false;
|
||||
const searchButtonText = componentConfig.searchButtonText ?? "검색";
|
||||
const helperText = componentConfig.helperText;
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const onSearch =
|
||||
typeof (props as any).onSearch === "function"
|
||||
? ((props as any).onSearch as (params: Record<string, any>) => void)
|
||||
: undefined;
|
||||
|
||||
const handleSearch = () => {
|
||||
if (isDesignMode) return;
|
||||
// TODO: DataPort 연결 (Phase F)
|
||||
// v2EventBus.emit('search:executed', { keyword })
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const fillWrapper = isDesignMode === true;
|
||||
|
||||
const emit = (kw: string) => {
|
||||
if (isDesignMode || !onSearch) return;
|
||||
const trimmed = kw.trim();
|
||||
onSearch(trimmed ? { keyword: trimmed } : {});
|
||||
};
|
||||
|
||||
const handleSearch = () => emit(keyword);
|
||||
|
||||
const handleReset = () => {
|
||||
setKeyword("");
|
||||
emit("");
|
||||
};
|
||||
|
||||
// autoSearch: 300ms 디바운스
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!autoSearch || isDesignMode) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => emit(keyword), 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keyword, autoSearch, isDesignMode]);
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...(fillWrapper ? { height: "100%" } : { height: "auto" }),
|
||||
display: "flex",
|
||||
flexDirection: layout === "inline" ? "row" : "column",
|
||||
alignItems: layout === "inline" ? "center" : "stretch",
|
||||
@@ -83,6 +105,7 @@ export const SearchComponent: React.FC<SearchComponentProps> = ({
|
||||
padding: "6px 8px",
|
||||
boxSizing: "border-box",
|
||||
background: "transparent",
|
||||
alignSelf: fillWrapper ? "stretch" : "center",
|
||||
...(component as any).style,
|
||||
...style,
|
||||
};
|
||||
@@ -191,6 +214,9 @@ export const SearchComponent: React.FC<SearchComponentProps> = ({
|
||||
// 기타 noise
|
||||
disabled: _58,
|
||||
required: _59,
|
||||
// Search ↔ Table 연동 props — DOM 에 흘리지 않음
|
||||
onSearch: _60,
|
||||
searchParams: _61,
|
||||
...domProps
|
||||
} = props as any;
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useMemo, useState, useEffect } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SearchConfig } from "./types";
|
||||
|
||||
/**
|
||||
@@ -91,25 +102,89 @@ export const SearchConfigPanel: React.FC<SearchConfigPanelProps> = ({
|
||||
const sel = "border-border bg-background w-full rounded border px-2 py-1 text-xs";
|
||||
const lbl = "text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase";
|
||||
|
||||
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||
const selectedOption = tableOptions.find((t) => t.value === connectedTable);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3 text-xs">
|
||||
{/* ── 테이블 연결 ── */}
|
||||
{/* ── 테이블 연결 (검색 가능 콤보박스) ── */}
|
||||
{tableOptions.length > 0 && (
|
||||
<div className="border-border rounded border p-2">
|
||||
<label className={lbl}>테이블 연결</label>
|
||||
<select
|
||||
value={connectedTable || ""}
|
||||
onChange={(e) => {
|
||||
onTableChange?.(e.target.value);
|
||||
patch({ selectedTable: e.target.value } as any);
|
||||
}}
|
||||
className={sel}
|
||||
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboOpen}
|
||||
className={cn(sel, "flex items-center justify-between text-left")}
|
||||
>
|
||||
<option value="">선택...</option>
|
||||
<span className={cn("truncate", !connectedTable && "text-muted-foreground")}>
|
||||
{selectedOption?.label ?? connectedTable ?? "선택..."}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-0" align="start">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
if (!search) return 1;
|
||||
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
|
||||
}}
|
||||
>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList className="max-h-[300px]">
|
||||
<CommandEmpty className="py-4 text-center text-xs text-muted-foreground">
|
||||
일치하는 테이블 없음
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value=""
|
||||
onSelect={() => {
|
||||
onTableChange?.("");
|
||||
patch({ selectedTable: "" } as any);
|
||||
setTableComboOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
!connectedTable ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground">선택...</span>
|
||||
</CommandItem>
|
||||
{tableOptions.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
<CommandItem
|
||||
key={t.value}
|
||||
value={`${t.value} ${t.label}`}
|
||||
onSelect={() => {
|
||||
onTableChange?.(t.value);
|
||||
patch({ selectedTable: t.value } as any);
|
||||
setTableComboOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3 shrink-0",
|
||||
connectedTable === t.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 truncate">{t.label}</span>
|
||||
{t.label !== t.value && (
|
||||
<span className="ml-2 shrink-0 text-[0.6rem] text-muted-foreground truncate max-w-[100px]">
|
||||
{t.value}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</select>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{connectedTable && tableColumns && tableColumns.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -110,6 +110,12 @@ export const TableComponent: React.FC<TableComponentProps> = ({
|
||||
const showToolbar = componentConfig.showToolbar ?? true;
|
||||
const emptyMessage = componentConfig.emptyMessage ?? "데이터가 없습니다";
|
||||
|
||||
// ─── 외부 검색 파라미터 (Search 컴포넌트가 onSearch 로 업데이트) ───
|
||||
const externalSearch =
|
||||
(props as any).searchParams && typeof (props as any).searchParams === "object"
|
||||
? ((props as any).searchParams as Record<string, any>)
|
||||
: undefined;
|
||||
|
||||
// ─── 데이터 fetch ───
|
||||
const tableData = useTableData({
|
||||
tableName,
|
||||
@@ -117,6 +123,7 @@ export const TableComponent: React.FC<TableComponentProps> = ({
|
||||
pageSize: isDesignMode
|
||||
? Math.min(componentConfig.pagination?.pageSize ?? DESIGN_PREVIEW_ROWS, DESIGN_PREVIEW_ROWS)
|
||||
: componentConfig.pagination?.pageSize ?? 20,
|
||||
search: isDesignMode ? undefined : externalSearch,
|
||||
});
|
||||
|
||||
// ─── 행 선택 ───
|
||||
@@ -170,6 +177,8 @@ export const TableComponent: React.FC<TableComponentProps> = ({
|
||||
splitRatio: _63, groupBy: _64, pivotRows: _65, pivotColumns: _66, pivotValues: _67,
|
||||
emptyMessage: _68, showToolbar: _69, showExcel: _70, showRefresh: _71,
|
||||
disabled: _72, required: _73,
|
||||
// Search ↔ Table 연동 props — DOM 에 흘리지 않음
|
||||
onSearch: _74, searchParams: _75,
|
||||
...domProps
|
||||
} = props as any;
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
|
||||
@@ -31,6 +31,12 @@ export interface TemplateSavePayload {
|
||||
create?: Record<string, any>[];
|
||||
edit?: Record<string, any>[];
|
||||
};
|
||||
/** 뷰별 해상도 */
|
||||
viewScreenResolutions?: {
|
||||
list?: Record<string, any>;
|
||||
create?: Record<string, any>;
|
||||
edit?: Record<string, any>;
|
||||
};
|
||||
/** 템플릿 수준 필드 규격 (아직 미사용, 확장용) */
|
||||
fields?: Record<string, any>[];
|
||||
/** DataPort 연결 (아직 미사용, 확장용) */
|
||||
@@ -49,6 +55,11 @@ export async function saveTemplate(p: TemplateSavePayload): Promise<void> {
|
||||
edit: p.v2Views?.edit ?? [],
|
||||
gridSettings: p.layout.gridSettings,
|
||||
screenResolution: p.layout.screenResolution,
|
||||
screenResolutions: p.viewScreenResolutions ?? {
|
||||
list: p.layout.screenResolution,
|
||||
create: p.layout.screenResolution,
|
||||
edit: p.layout.screenResolution,
|
||||
},
|
||||
mainTableName: p.primaryTable,
|
||||
};
|
||||
const payload: Record<string, any> = {
|
||||
@@ -97,6 +108,11 @@ export interface LoadedTemplate {
|
||||
};
|
||||
primaryTable: string;
|
||||
screenResolution?: Record<string, any>;
|
||||
viewScreenResolutions?: {
|
||||
list?: Record<string, any>;
|
||||
create?: Record<string, any>;
|
||||
edit?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonMaybe(raw: any): any {
|
||||
@@ -128,6 +144,11 @@ export async function loadTemplateAsLayout(
|
||||
const listV2 = Array.isArray(viewsObj.list) ? viewsObj.list : [];
|
||||
const createV2 = Array.isArray(viewsObj.create) ? viewsObj.create : [];
|
||||
const editV2 = Array.isArray(viewsObj.edit) ? viewsObj.edit : [];
|
||||
const viewScreenResolutions = viewsObj.screenResolutions ?? {
|
||||
list: viewsObj.screenResolution,
|
||||
create: viewsObj.screenResolution,
|
||||
edit: viewsObj.screenResolution,
|
||||
};
|
||||
|
||||
const legacyListLayout = convertV2ToLegacy({
|
||||
components: listV2,
|
||||
@@ -157,5 +178,6 @@ export async function loadTemplateAsLayout(
|
||||
primaryTable:
|
||||
template.primary_table ?? template.PRIMARY_TABLE ?? viewsObj.mainTableName ?? "",
|
||||
screenResolution: viewsObj.screenResolution,
|
||||
viewScreenResolutions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
/* ── 콘텐츠 영역 ── */
|
||||
.dash-content {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -124,7 +125,12 @@
|
||||
|
||||
/* ── 캔버스 ── */
|
||||
.dash-canvas {
|
||||
display: block;
|
||||
align-self: stretch;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
@@ -145,7 +151,7 @@
|
||||
background: var(--v5-surface-solid);
|
||||
border: 1px solid var(--v5-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 0 20px rgba(var(--v5-primary-rgb),.10);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -155,19 +161,30 @@
|
||||
.dark .dash-card {
|
||||
background: var(--v5-surface-solid);
|
||||
border-color: var(--v5-border);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.4), 0 0 20px rgba(var(--v5-primary-rgb),.15);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.3);
|
||||
}
|
||||
.dark .dash-card { box-shadow: 0 2px 8px rgba(0,0,0,0.24); }
|
||||
.dash-card:hover { border-color: var(--v5-border); box-shadow: inherit; }
|
||||
.dash-canvas.edit-mode .dash-card {
|
||||
cursor: move;
|
||||
border-style: solid;
|
||||
border-color: rgba(var(--v5-primary-rgb),.3);
|
||||
}
|
||||
.dash-canvas.edit-mode .dash-card-head,
|
||||
.dash-canvas.edit-mode .dash-mini-body,
|
||||
.dash-canvas.edit-mode .dash-card-body {
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
.dash-canvas.edit-mode .dash-card-body > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
.dark .dash-card { box-shadow: 0 8px 32px rgba(0,0,0,0.4), var(--v5-glow-sm); }
|
||||
.dash-card:hover { border-color: rgba(var(--v5-primary-rgb),.25);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.08), var(--v5-glow-md); }
|
||||
.dash-canvas.edit-mode .dash-card { cursor: move;
|
||||
border-style: solid; border-color: rgba(var(--v5-primary-rgb),.3); }
|
||||
.dash-card.dragging {
|
||||
box-shadow: 0 24px 60px rgba(var(--v5-primary-rgb),.35), var(--v5-glow-lg, var(--v5-glow-md));
|
||||
box-shadow: 0 0 0 1px rgba(var(--v5-primary-rgb),.35);
|
||||
border-color: var(--v5-primary); z-index: 50;
|
||||
}
|
||||
.dash-card.resizing {
|
||||
box-shadow: 0 24px 60px rgba(var(--v5-cyan-rgb),.3), var(--v5-glow-lg, var(--v5-glow-md));
|
||||
box-shadow: 0 0 0 1px rgba(var(--v5-cyan-rgb),.38);
|
||||
border-color: var(--v5-cyan); z-index: 50;
|
||||
}
|
||||
|
||||
@@ -293,19 +310,65 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 리사이즈 핸들 */
|
||||
/* 리사이즈 핸들 — 8방향 (edit mode 에서만 표시)
|
||||
★ DashboardCard 가 <div className="dash-resize-handle {dir}" data-resize="{dir}"/> 8개 렌더
|
||||
*/
|
||||
.dash-resize-handle {
|
||||
position: absolute; right: 0; bottom: 0; width: 18px; height: 18px;
|
||||
cursor: nwse-resize; display: none; align-items: center; justify-content: center;
|
||||
color: var(--v5-text-muted); opacity: .6; transition: opacity .2s;
|
||||
position: absolute; display: none; z-index: 20;
|
||||
color: var(--v5-text-muted); opacity: 0; transition: opacity .2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dash-resize-handle:hover { opacity: 1; color: var(--v5-primary); }
|
||||
.dash-canvas.edit-mode .dash-resize-handle { display: flex; }
|
||||
.dash-resize-handle::before {
|
||||
content: ''; position: absolute; right: 3px; bottom: 3px; width: 10px; height: 10px;
|
||||
background: linear-gradient(135deg, transparent 50%, currentColor 50%, currentColor 60%,
|
||||
transparent 60%, transparent 70%, currentColor 70%, currentColor 80%, transparent 80%);
|
||||
.dash-canvas.edit-mode .dash-resize-handle { display: block; }
|
||||
.dash-canvas.edit-mode [data-card-id]:hover .dash-resize-handle {
|
||||
opacity: .55;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.dash-canvas.edit-mode .dash-resize-handle:hover { opacity: 1; color: var(--v5-primary); }
|
||||
|
||||
/* 변(edge) 핸들 — 카드 전체 변을 다 먹지 않도록 중앙 짧은 구간만 활성화 */
|
||||
.dash-resize-handle.n { top: -3px; left: calc(50% - 36px); width: 72px; height: 6px; cursor: ns-resize; }
|
||||
.dash-resize-handle.s { bottom: -3px; left: calc(50% - 36px); width: 72px; height: 6px; cursor: ns-resize; }
|
||||
.dash-resize-handle.e { right: -3px; top: calc(50% - 36px); width: 6px; height: 72px; cursor: ew-resize; }
|
||||
.dash-resize-handle.w { left: -3px; top: calc(50% - 36px); width: 6px; height: 72px; cursor: ew-resize; }
|
||||
|
||||
/* 코너(corner) 핸들 — 16x16 정사각형, 대각 커서 */
|
||||
.dash-resize-handle.ne { top: -4px; right: -4px; width: 14px; height: 14px; cursor: nesw-resize; }
|
||||
.dash-resize-handle.nw { top: -4px; left: -4px; width: 14px; height: 14px; cursor: nwse-resize; }
|
||||
.dash-resize-handle.se { bottom: -4px; right: -4px; width: 14px; height: 14px; cursor: nwse-resize; }
|
||||
.dash-resize-handle.sw { bottom: -4px; left: -4px; width: 14px; height: 14px; cursor: nesw-resize; }
|
||||
|
||||
/* 코너 시각 표식 */
|
||||
.dash-resize-handle.ne::before,
|
||||
.dash-resize-handle.nw::before,
|
||||
.dash-resize-handle.se::before,
|
||||
.dash-resize-handle.sw::before {
|
||||
content: ''; position: absolute; width: 8px; height: 8px;
|
||||
border: 2px solid currentColor; border-radius: 2px; background: var(--v5-surface-solid);
|
||||
}
|
||||
.dash-resize-handle.ne::before { top: 0; right: 0; }
|
||||
.dash-resize-handle.nw::before { top: 0; left: 0; }
|
||||
.dash-resize-handle.se::before { bottom: 0; right: 0; }
|
||||
.dash-resize-handle.sw::before { bottom: 0; left: 0; }
|
||||
|
||||
/* 스냅 가이드 라인 — 드래그 중 다른 카드/캔버스 경계에 달라붙을 때 시각 표시 */
|
||||
.dash-snap-guide {
|
||||
position: absolute; pointer-events: none; z-index: 100;
|
||||
opacity: .95;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(90deg,
|
||||
rgba(var(--v5-primary-rgb), 0),
|
||||
rgba(var(--v5-primary-rgb), .9) 18%,
|
||||
rgba(var(--v5-cyan-rgb), .95) 50%,
|
||||
rgba(var(--v5-primary-rgb), .9) 82%,
|
||||
rgba(var(--v5-primary-rgb), 0)
|
||||
);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(var(--v5-cyan-rgb), .22),
|
||||
0 0 14px rgba(var(--v5-cyan-rgb), .28);
|
||||
}
|
||||
.dash-snap-guide.v { width: 2px; }
|
||||
.dash-snap-guide.h { height: 2px; }
|
||||
|
||||
/* 접힌 카드 */
|
||||
.dash-card.collapsed .dash-card-body { display: none; }
|
||||
@@ -743,21 +806,28 @@
|
||||
color: rgb(var(--v5-primary-rgb));
|
||||
background: transparent;
|
||||
}
|
||||
.ud-htool.on::before {
|
||||
/* 언더라인 — 상시 존재, on/off 전환 시 양방향 scaleX transition */
|
||||
.ud-htool::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px; right: 14px; bottom: 4px;
|
||||
height: 2px;
|
||||
background: rgb(var(--v5-primary-rgb));
|
||||
border-radius: 2px;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
animation: ud-htool-underline .35s var(--v5-ease-move) both;
|
||||
opacity: 0;
|
||||
transition:
|
||||
transform .35s var(--v5-ease-move),
|
||||
opacity .3s var(--v5-ease-move),
|
||||
background-color .22s var(--v5-ease-move),
|
||||
box-shadow .22s var(--v5-ease-move);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 8px rgba(var(--v5-primary-rgb), .45);
|
||||
}
|
||||
@keyframes ud-htool-underline {
|
||||
from { transform: scaleX(0); opacity: 0; }
|
||||
to { transform: scaleX(1); opacity: 1; }
|
||||
.ud-htool.on::before {
|
||||
transform: scaleX(1);
|
||||
opacity: 1;
|
||||
}
|
||||
.ud-htool.on:hover { filter: brightness(1.1); }
|
||||
|
||||
|
||||
@@ -110,15 +110,15 @@
|
||||
--v5-green-rgb:85,239,196;
|
||||
--v5-amber-rgb:255,234,167;
|
||||
|
||||
--v5-bg:#06050e; --v5-bg-subtle:#0c0b18;
|
||||
--v5-surface:rgba(17,16,42,0.5); --v5-surface-solid:#11102a;
|
||||
--v5-surface-hover:#191840;
|
||||
--v5-text:#eae8f4; --v5-text-sec:#8d8ba8; --v5-text-muted:#5a587a;
|
||||
--v5-bg:#0a0b0d; --v5-bg-subtle:#111215;
|
||||
--v5-surface:rgba(23,24,27,0.5); --v5-surface-solid:#17181b;
|
||||
--v5-surface-hover:#1f2025;
|
||||
--v5-text:#ebecee; --v5-text-sec:#8e8f92; --v5-text-muted:#5a5b5e;
|
||||
--v5-primary:rgb(var(--v5-primary-rgb)); --v5-primary-light:#c8c4ff; --v5-primary-glow:rgba(var(--v5-primary-rgb),0.25);
|
||||
--v5-cyan:rgb(var(--v5-cyan-rgb)); --v5-cyan-glow:rgba(var(--v5-cyan-rgb),0.15);
|
||||
--v5-pink:rgb(var(--v5-pink-rgb)); --v5-red:rgb(var(--v5-red-rgb)); --v5-green:rgb(var(--v5-green-rgb)); --v5-amber:rgb(var(--v5-amber-rgb));
|
||||
--v5-border:rgba(var(--v5-primary-rgb),0.1); --v5-border-subtle:rgba(255,255,255,0.04);
|
||||
--v5-glass:rgba(17,16,42,0.45); --v5-glass-strong:rgba(17,16,42,0.65);
|
||||
--v5-border:rgba(255,255,255,0.08); --v5-border-subtle:rgba(255,255,255,0.04);
|
||||
--v5-glass:rgba(23,24,27,0.45); --v5-glass-strong:rgba(23,24,27,0.65);
|
||||
--v5-glass-border:rgba(var(--v5-primary-rgb),0.12);
|
||||
--v5-glow-sm:0 0 20px rgba(var(--v5-primary-rgb),0.1);
|
||||
--v5-glow-md:0 0 40px rgba(var(--v5-primary-rgb),0.18);
|
||||
@@ -870,7 +870,7 @@ html:not(.dark) .v5-side{
|
||||
--v5-mm-text:#1a1825;
|
||||
--v5-mm-text-sec:#4e4c5e;
|
||||
--v5-mm-text-muted:#8a8899;
|
||||
--v5-mm-accent:#6c5ce7;
|
||||
--v5-mm-accent:rgb(var(--v5-primary-rgb));
|
||||
--v5-mm-accent-soft:rgba(var(--v5-primary-rgb),.08);
|
||||
--v5-mm-accent-line:rgba(var(--v5-primary-rgb),.22);
|
||||
--v5-mm-ring:rgba(var(--v5-primary-rgb),.14);
|
||||
@@ -879,20 +879,22 @@ html:not(.dark) .v5-side{
|
||||
--v5-mm-amber:#c87d18;
|
||||
}
|
||||
.dark{
|
||||
--v5-mm-bg:#0a0a12;
|
||||
--v5-mm-panel:#13131c;
|
||||
--v5-mm-sunk:#0f0f18;
|
||||
--v5-mm-hover:#1a1a24;
|
||||
--v5-mm-faint:#4a4859;
|
||||
--v5-mm-border:#1f1f26;
|
||||
--v5-mm-border-2:#19191f;
|
||||
--v5-mm-text:#f1eff8;
|
||||
--v5-mm-text-sec:#a5a2ba;
|
||||
--v5-mm-text-muted:#72708a;
|
||||
--v5-mm-accent:#a991ff;
|
||||
--v5-mm-accent-soft:rgba(169,145,255,.09);
|
||||
--v5-mm-accent-line:rgba(169,145,255,.22);
|
||||
--v5-mm-ring:rgba(169,145,255,.22);
|
||||
/* 무채색 베이스 — 메인 --v5-surface-solid(#17181b) 와 톤 맞춤.
|
||||
보라빛은 제거하고 테마색은 accent-* 토큰으로만 은은히 들어옴. */
|
||||
--v5-mm-bg:#0a0a0c;
|
||||
--v5-mm-panel:#161618;
|
||||
--v5-mm-sunk:#111113;
|
||||
--v5-mm-hover:#1c1c1f;
|
||||
--v5-mm-faint:#4a4a4e;
|
||||
--v5-mm-border:#26262a;
|
||||
--v5-mm-border-2:#1c1c20;
|
||||
--v5-mm-text:#f2f2f4;
|
||||
--v5-mm-text-sec:#a5a5ac;
|
||||
--v5-mm-text-muted:#72727a;
|
||||
--v5-mm-accent:rgb(var(--v5-primary-rgb));
|
||||
--v5-mm-accent-soft:rgba(var(--v5-primary-rgb),.09);
|
||||
--v5-mm-accent-line:rgba(var(--v5-primary-rgb),.22);
|
||||
--v5-mm-ring:rgba(var(--v5-primary-rgb),.22);
|
||||
--v5-mm-green:#5eeabc;
|
||||
--v5-mm-red:#ff6a7a;
|
||||
--v5-mm-amber:#f2b45e;
|
||||
@@ -966,9 +968,10 @@ html:not(.dark) .v5-side{
|
||||
.dark .v5-mm-node.on{background:rgba(var(--v5-primary-rgb),.09);}
|
||||
.v5-mm-node.on .v5-mm-node-name{color:var(--v5-mm-accent);font-weight:600;}
|
||||
|
||||
.v5-mm-caret{width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;
|
||||
color:var(--v5-mm-text-muted);flex-shrink:0;border-radius:3px;transition:all .1s;}
|
||||
.v5-mm-caret:hover{background:var(--v5-mm-panel);color:var(--v5-mm-text);}
|
||||
.v5-mm-caret{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;
|
||||
color:var(--v5-mm-text-muted);flex-shrink:0;border-radius:6px;transition:all .1s;margin:-4px 0;}
|
||||
.v5-mm-caret:hover{background:var(--v5-mm-hover);color:var(--v5-mm-text);}
|
||||
.dark .v5-mm-caret:hover{background:rgba(255,255,255,.06);}
|
||||
.v5-mm-caret svg{width:9px;height:9px;transition:transform .18s cubic-bezier(.4,0,.2,1);}
|
||||
.v5-mm-node.open > .v5-mm-caret svg{transform:rotate(90deg);}
|
||||
.v5-mm-node.leaf .v5-mm-caret{opacity:0;pointer-events:none;}
|
||||
@@ -1000,7 +1003,7 @@ html:not(.dark) .v5-side{
|
||||
.dark .v5-mm-node.l1:hover{border-color:rgba(var(--v5-primary-rgb),.2);background:rgba(var(--v5-primary-rgb),.05);}
|
||||
.v5-mm-node.l1.on{border-color:var(--v5-mm-accent);background:rgba(var(--v5-primary-rgb),.08);}
|
||||
.dark .v5-mm-node.l1.on{background:rgba(var(--v5-primary-rgb),.08);}
|
||||
.v5-mm-node.l1 .v5-mm-caret{width:18px;height:18px;}
|
||||
.v5-mm-node.l1 .v5-mm-caret{width:28px;height:28px;border-radius:8px;}
|
||||
.v5-mm-node.l1 .v5-mm-caret svg{width:11px;height:11px;}
|
||||
.v5-mm-node.l1 .v5-mm-node-ico{width:38px;height:38px;border-radius:10px;}
|
||||
.v5-mm-node.l1 .v5-mm-node-ico svg{width:18px;height:18px;}
|
||||
@@ -1011,32 +1014,27 @@ html:not(.dark) .v5-side{
|
||||
/* L2/L3 들여쓰기 + connector line */
|
||||
.v5-mm-node.l2{padding-left:2.1rem;}
|
||||
.v5-mm-node.l3{padding-left:3.3rem;}
|
||||
.v5-mm-node.l2::before{content:'';position:absolute;left:1.4rem;top:0;bottom:0;width:1px;background:var(--v5-mm-border-2);}
|
||||
.v5-mm-node.l2::after{content:'';position:absolute;left:1.4rem;top:50%;width:.5rem;height:1px;background:var(--v5-mm-border);}
|
||||
.v5-mm-node.l3::before{content:'';position:absolute;left:2.5rem;top:0;bottom:0;width:1px;background:var(--v5-mm-border-2);}
|
||||
.v5-mm-node.l3::after{content:'';position:absolute;left:2.5rem;top:50%;width:.5rem;height:1px;background:var(--v5-mm-border);}
|
||||
/* 가로 stub — 세로선은 .v5-mm-sub 컨테이너가 통합으로 그려 노드 간 끊김 없음 */
|
||||
.v5-mm-node.l2::after{content:'';position:absolute;left:1.4rem;top:50%;width:.7rem;height:1px;background:var(--v5-mm-border);}
|
||||
.v5-mm-node.l3::after{content:'';position:absolute;left:2.5rem;top:50%;width:.7rem;height:1px;background:var(--v5-mm-border);}
|
||||
.dark .v5-mm-node.l2::after,
|
||||
.dark .v5-mm-node.l3::after{background:rgba(255,255,255,.22);}
|
||||
|
||||
/* 서브트리 펼침 — stagger fade-in + blur */
|
||||
.v5-mm-sub{display:none;margin:0;padding:0;}
|
||||
.v5-mm-node.open + .v5-mm-sub{display:block;animation:v5-mm-subIn .35s cubic-bezier(.16,1,.3,1);}
|
||||
@keyframes v5-mm-subIn{
|
||||
from{opacity:0;transform:translateY(-6px);filter:blur(3px);}
|
||||
to{opacity:1;transform:none;filter:none;}
|
||||
/* 서브트리 — grid-template-rows 트릭으로 펼침/접힘 양방향 애니메이션 */
|
||||
.v5-mm-sub{
|
||||
display:grid;grid-template-rows:0fr;opacity:0;position:relative;
|
||||
transition:grid-template-rows .32s cubic-bezier(.16,1,.3,1),opacity .22s ease;
|
||||
}
|
||||
.v5-mm-node.open + .v5-mm-sub > .v5-mm-node{animation:v5-mm-nodeIn .38s cubic-bezier(.16,1,.3,1) backwards;}
|
||||
.v5-mm-node.open + .v5-mm-sub > .v5-mm-node:nth-child(1){animation-delay:.03s;}
|
||||
.v5-mm-node.open + .v5-mm-sub > .v5-mm-node:nth-child(2){animation-delay:.07s;}
|
||||
.v5-mm-node.open + .v5-mm-sub > .v5-mm-node:nth-child(3){animation-delay:.11s;}
|
||||
.v5-mm-node.open + .v5-mm-sub > .v5-mm-node:nth-child(4){animation-delay:.15s;}
|
||||
.v5-mm-node.open + .v5-mm-sub > .v5-mm-node:nth-child(5){animation-delay:.19s;}
|
||||
.v5-mm-node.open + .v5-mm-sub > .v5-mm-node:nth-child(6){animation-delay:.23s;}
|
||||
.v5-mm-node.open + .v5-mm-sub > .v5-mm-node:nth-child(7){animation-delay:.27s;}
|
||||
.v5-mm-node.open + .v5-mm-sub > .v5-mm-node:nth-child(8){animation-delay:.31s;}
|
||||
.v5-mm-node.open + .v5-mm-sub > .v5-mm-node:nth-child(n+9){animation-delay:.35s;}
|
||||
@keyframes v5-mm-nodeIn{
|
||||
from{opacity:0;transform:translateX(-10px) scale(.98);}
|
||||
to{opacity:1;transform:none;}
|
||||
.v5-mm-sub.is-open{grid-template-rows:1fr;opacity:1;}
|
||||
.v5-mm-sub > .v5-mm-sub-inner{overflow:hidden;min-height:0;}
|
||||
|
||||
/* 통합 세로선 — L2 기본, 중첩 시 L3 들여쓰기 */
|
||||
.v5-mm-sub::before{
|
||||
content:'';position:absolute;left:1.4rem;top:0;bottom:0;width:1px;
|
||||
background:var(--v5-mm-border-2);pointer-events:none;
|
||||
}
|
||||
.v5-mm-sub .v5-mm-sub::before{left:2.5rem;}
|
||||
.dark .v5-mm-sub::before{background:rgba(255,255,255,.18);}
|
||||
|
||||
/* caret 회전 부드럽게 */
|
||||
.v5-mm-caret svg{transition:transform .28s cubic-bezier(.34,1.56,.64,1);}
|
||||
@@ -1220,6 +1218,20 @@ select.v5-mm-inp{padding-right:2rem;appearance:none;-webkit-appearance:none;
|
||||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239998ad' stroke-width='2'><path d='m6 9 6 6 6-6'/></svg>");
|
||||
background-repeat:no-repeat;background-position:right .65rem center;background-size:12px;}
|
||||
|
||||
/* MenuIconPicker 슬롯 — 내부 shadcn Button(h-10=40px) 을 v5-mm-inp 높이(36px) 로 정렬 */
|
||||
.v5-mm-iconpicker-slot > .space-y-2{margin-top:0;}
|
||||
.v5-mm-iconpicker-slot .menu-icon-picker-trigger{
|
||||
height:36px!important;min-height:36px!important;
|
||||
border-radius:8px!important;font-size:.78rem!important;
|
||||
}
|
||||
.v5-mm-iconpicker-slot .menu-icon-picker-option{
|
||||
height:auto!important;min-height:68px!important;
|
||||
border-radius:8px!important;font-size:inherit!important;
|
||||
}
|
||||
.v5-mm-iconpicker-slot .menu-icon-picker-option svg{
|
||||
width:20px;height:20px;flex-shrink:0;
|
||||
}
|
||||
|
||||
.v5-mm-seg{display:inline-flex;padding:3px;border-radius:9px;background:var(--v5-mm-sunk);border:1px solid var(--v5-mm-border);width:fit-content;}
|
||||
.v5-mm-seg button{padding:.45rem .9rem;border:none;border-radius:6px;background:transparent;color:var(--v5-mm-text-muted);
|
||||
font-family:inherit;font-size:.72rem;font-weight:600;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:.35rem;}
|
||||
@@ -1253,10 +1265,12 @@ select.v5-mm-inp{padding-right:2rem;appearance:none;-webkit-appearance:none;
|
||||
.v5-mm-btn svg{width:13px;height:13px;}
|
||||
.v5-mm-btn.primary{background:var(--v5-mm-accent);color:white;border-color:var(--v5-mm-accent);
|
||||
box-shadow:0 2px 6px -1px rgba(var(--v5-primary-rgb),.35),inset 0 1px 0 rgba(255,255,255,.22);}
|
||||
.v5-mm-btn.primary:hover{background:#5c4ed4;border-color:#5c4ed4;transform:translateY(-1px);
|
||||
.v5-mm-btn.primary:hover{background:color-mix(in srgb,rgb(var(--v5-primary-rgb)) 88%,#000);
|
||||
border-color:color-mix(in srgb,rgb(var(--v5-primary-rgb)) 88%,#000);transform:translateY(-1px);
|
||||
box-shadow:0 4px 12px -2px rgba(var(--v5-primary-rgb),.4);}
|
||||
.dark .v5-mm-btn.primary{color:var(--v5-mm-bg);}
|
||||
.dark .v5-mm-btn.primary:hover{background:#b8b2ff;border-color:#b8b2ff;color:var(--v5-mm-bg);}
|
||||
.dark .v5-mm-btn.primary:hover{background:color-mix(in srgb,rgb(var(--v5-primary-rgb)) 85%,#fff);
|
||||
border-color:color-mix(in srgb,rgb(var(--v5-primary-rgb)) 85%,#fff);color:var(--v5-mm-bg);}
|
||||
.v5-mm-btn.danger{color:var(--v5-mm-red);}
|
||||
.v5-mm-btn.danger:hover{border-color:var(--v5-mm-red);background:rgba(var(--v5-red-rgb),.05);}
|
||||
.v5-mm-btn.sm{height:28px;padding:0 .7rem;font-size:.66rem;}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user