사용자 대시보드 기능강화 및 인비온 스튜디오 메뉴관리 자잘한수정
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m22s

This commit is contained in:
DDD1542
2026-04-22 18:27:06 +09:00
parent a5de92de65
commit 3eda684787
33 changed files with 3964 additions and 560 deletions
@@ -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) {}
}
+1 -12
View File
@@ -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>
+232
View File
@@ -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
View File
@@ -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;
+3 -2
View File
@@ -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 컴포넌트 마운트됨");
+18 -10
View File
@@ -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>
)}
</>
);
+529 -101
View File
@@ -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">
+105 -142
View File
@@ -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>
);
}
+18 -11
View File
@@ -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
+18 -3
View File
@@ -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") && (
+5 -3
View File
@@ -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"));
}
}
+14 -6
View File
@@ -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,
},
};
},
+92 -12
View File
@@ -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 */
+22
View File
@@ -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,
};
}
+94 -24
View File
@@ -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); }
+64 -50
View File
@@ -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