- RuleEditor simplificado: una sola acción por regla (tipo attribute), sin selector de tipo de acción ni múltiples acciones - Acciones de regla con estado calculado en tiempo real (rule-eval.ts): ObjInfoPanel y AttDetail muestran badge con estado actual según valores por defecto del catálogo - Grafo de dependencias implementado en CatalogLayout (overlay) - IDs de objetos/secciones/atributos especificados por el usuario con prefijo automático (ob:slug, sc:slug, at:slug) y validación de unicidad - Formularios de creación con nombre + slug; slug se auto-rellena desde el nombre y puede editarse manualmente - Botón Descartar en header para revertir cambios no guardados - canvas/Editor.svelte persiste en localStorage (Guardar funciona) - Target de acciones acotado al contenedor de la regla (no catálogo global) - Paneles ObjInfo y SecInfo muestran campo ID como solo lectura Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>master
parent
6600f2c939
commit
2301149e58
@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="es" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VICE — Catalog Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script type="module" src="/src/vcen-edit/main.ts"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@ -1,116 +1,25 @@
|
||||
<script lang="ts">
|
||||
import './app.css';
|
||||
import ConditionBuilderDemo from "@/vcen-edit/demos/ConditionBuilderDemo.svelte";
|
||||
import CatalogDemo from "@/vcen-edit/demos/CatalogDemo.svelte";
|
||||
import Editor from "@/vcen-edit/canvas/Editor.svelte";
|
||||
import LingInput from "@/ling/svelte/LingInput2.svelte";
|
||||
import type {LingRecord, SupportedLocale} from "@/ling/lib";
|
||||
|
||||
|
||||
const locales4 = ['es', 'en', 'fr', 'de'];
|
||||
const locales2 = ['es', 'en'];
|
||||
|
||||
let titulo = $state({ es: '' });
|
||||
let descripcion = $state({ es: '' });
|
||||
let nombre = $state({ es: 'Hola mundo', en: 'Hello world' } as LingRecord);
|
||||
|
||||
const defaultLocale = "es";
|
||||
|
||||
let isValid = $derived (
|
||||
(titulo[defaultLocale]?.length ?? 0) > 3
|
||||
)
|
||||
|
||||
|
||||
|
||||
import CatalogEditor from '@/vcen-edit/comps/CatalogEditor.svelte';
|
||||
import type { Cat } from '@/vcen/lib';
|
||||
|
||||
const demoCat: Cat = {
|
||||
id: 'ct_demo' as Cat['id'],
|
||||
name: 'Catálogo demo',
|
||||
atts: [],
|
||||
opts: [],
|
||||
optCategories: [],
|
||||
objs: [],
|
||||
};
|
||||
|
||||
let cat = $state<Cat>(demoCat);
|
||||
|
||||
function onSave(updated: Cat) {
|
||||
cat = updated;
|
||||
console.log('Catálogo guardado:', updated);
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="min-h-screen bg-gray-50 px-4 py-12">
|
||||
|
||||
|
||||
|
||||
<div class="mx-auto flex max-w-xl flex-col gap-8">
|
||||
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight text-slate-900">
|
||||
LingInput <span class="text-blue-600">Demo</span>
|
||||
</h1>
|
||||
<p class="mt-1 text-xs text-slate-400">
|
||||
Svelte 5 + Tailwind CSS 4 ·
|
||||
<code class="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500">LingRecord</code>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Demo 1: input + 2 locales -->
|
||||
<section class="flex flex-col gap-4 rounded-2xl border border-slate-100 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-xs font-bold uppercase tracking-widest text-slate-400">
|
||||
1 · Input · 2 locales · required
|
||||
</h2>
|
||||
<LingInput
|
||||
defaultLocale="es"
|
||||
value={titulo}
|
||||
locales={locales2}
|
||||
label="Título"
|
||||
as="input"
|
||||
required
|
||||
invalid={!isValid}
|
||||
onUpdate={(v) => titulo = v}
|
||||
|
||||
/>
|
||||
<pre class="overflow-auto rounded-lg border border-slate-100 bg-slate-50 p-3 text-xs text-slate-500">{JSON.stringify(titulo, null, 2)}</pre>
|
||||
</section>
|
||||
|
||||
<!-- Demo 2: textarea + 4 locales -->
|
||||
<section class="flex flex-col gap-4 rounded-2xl border border-slate-100 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-xs font-bold uppercase tracking-widest text-slate-400">
|
||||
2 · Textarea · 4 locales
|
||||
</h2>
|
||||
<LingInput
|
||||
defaultLocale="es"
|
||||
value={descripcion}
|
||||
locales={locales4}
|
||||
label="Descripción"
|
||||
rows={4}
|
||||
onUpdate={(v) => descripcion = v}
|
||||
/>
|
||||
<pre class="overflow-auto rounded-lg border border-slate-100 bg-slate-50 p-3 text-xs text-slate-500">{JSON.stringify(descripcion, null, 2)}</pre>
|
||||
</section>
|
||||
|
||||
<!-- Demo 3: snippet personalizado + contenido inicial -->
|
||||
<section class="flex flex-col gap-4 rounded-2xl border border-slate-100 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-xs font-bold uppercase tracking-widest text-slate-400">
|
||||
3 · Snippet custom · Contenido inicial
|
||||
</h2>
|
||||
<LingInput
|
||||
defaultLocale="es"
|
||||
value={nombre}
|
||||
locales={locales4}
|
||||
label="Nombre"
|
||||
as="input"
|
||||
>
|
||||
{#snippet localeTab({ locale, active, hasContent, activate })}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-label={`Idioma ${locale}`}
|
||||
onclick={activate}
|
||||
class={[
|
||||
'h-8 w-10 cursor-pointer rounded-full border-2 text-xs font-bold transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-400/50',
|
||||
active
|
||||
? 'scale-110 border-blue-600 bg-blue-600 text-white'
|
||||
: hasContent
|
||||
? 'border-emerald-400 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
||||
: 'border-gray-200 bg-white text-gray-400 hover:border-gray-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
{/snippet}
|
||||
</LingInput>
|
||||
<pre class="overflow-auto rounded-lg border border-slate-100 bg-slate-50 p-3 text-xs text-slate-500">{JSON.stringify(nombre, null, 2)}</pre>
|
||||
</section>
|
||||
|
||||
<div class="h-screen w-screen overflow-hidden">
|
||||
<CatalogEditor cat={cat} onsave={onSave} />
|
||||
</div>
|
||||
</main>
|
||||
@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
import AttDetail from './AttDetail.svelte'
|
||||
|
||||
const editor = getContext<CatalogEditorStore>('catalog-editor')
|
||||
const sel = $derived(editor.selection)
|
||||
const atts = $derived(editor.cat.atts ?? [])
|
||||
const attId = $derived(sel.kind === 'cat-atts' ? sel.attId : undefined)
|
||||
|
||||
const domainBadge: Record<string, string> = {
|
||||
string: 'bg-blue-900/40 text-blue-400',
|
||||
number: 'bg-green-900/40 text-green-400',
|
||||
currency: 'bg-emerald-900/40 text-emerald-400',
|
||||
boolean: 'bg-purple-900/40 text-purple-400',
|
||||
option: 'bg-amber-900/40 text-amber-400',
|
||||
date: 'bg-rose-900/40 text-rose-400',
|
||||
time: 'bg-rose-900/40 text-rose-400',
|
||||
datetime: 'bg-rose-900/40 text-rose-400',
|
||||
}
|
||||
|
||||
let creating = $state(false)
|
||||
let newAttName = $state('')
|
||||
let newAttSlug = $state('')
|
||||
let attSlugError = $state('')
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text.toLowerCase()
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function confirmAdd() {
|
||||
const slug = newAttSlug.trim()
|
||||
if (!slug) { attSlugError = 'El ID es obligatorio'; return }
|
||||
if (editor.isAttSlugTaken(slug)) { attSlugError = 'ID ya existe'; return }
|
||||
editor.addCatAtt(newAttName.trim() || 'Atributo', slug)
|
||||
creating = false; newAttName = ''; newAttSlug = ''; attSlugError = ''
|
||||
}
|
||||
function cancelAdd() {
|
||||
creating = false; newAttName = ''; newAttSlug = ''; attSlugError = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full">
|
||||
<div class="w-56 shrink-0 border-r border-gray-800 flex flex-col">
|
||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800 shrink-0">
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Atributos ({atts.length})
|
||||
</span>
|
||||
<button onclick={() => { creating = true; newAttName = '' }}
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-gray-500
|
||||
hover:text-indigo-400 hover:bg-indigo-500/10 transition-colors">+</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto py-1">
|
||||
{#if atts.length === 0}
|
||||
<div class="px-4 py-6 text-center text-gray-700 text-xs italic">
|
||||
Sin atributos globales
|
||||
<button onclick={() => { creating = true; newAttName = '' }}
|
||||
class="block mx-auto mt-2 px-3 py-1 rounded border border-dashed border-gray-700
|
||||
text-gray-600 hover:text-indigo-400 hover:border-indigo-700 transition-colors">
|
||||
Añadir atributo
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#each atts as att (att.id)}
|
||||
{@const isActive = attId === att.id}
|
||||
<div
|
||||
role="button" tabindex="0"
|
||||
onclick={() => editor.select({ kind: 'cat-atts', attId: att.id })}
|
||||
onkeydown={e => e.key === 'Enter' && editor.select({ kind: 'cat-atts', attId: att.id })}
|
||||
class="px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer
|
||||
transition-colors group border-l-2
|
||||
{isActive
|
||||
? 'bg-indigo-600/20 text-indigo-300 border-indigo-500'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}"
|
||||
>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded shrink-0
|
||||
{domainBadge[att.value.domain.kind] ?? 'bg-gray-800 text-gray-500'}">
|
||||
{att.value.domain.kind.slice(0, 3)}
|
||||
</span>
|
||||
<span class="flex-1 truncate">
|
||||
{#if att.name}{att.name}{:else}<span class="italic text-gray-600">Sin nombre</span>{/if}
|
||||
</span>
|
||||
<button
|
||||
onclick={e => { e.stopPropagation(); editor.removeCatAtt(att.id) }}
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-opacity text-xs"
|
||||
>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if creating}
|
||||
<div class="px-3 py-2 border-t border-gray-800 bg-gray-900/60 flex flex-col gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newAttName}
|
||||
oninput={e => { if (!newAttSlug || newAttSlug === slugify(newAttName.slice(0,-1))) newAttSlug = slugify(e.currentTarget.value) }}
|
||||
placeholder="Nombre del atributo"
|
||||
class="field-input text-xs"
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-[10px] text-gray-600 shrink-0">at:</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newAttSlug}
|
||||
oninput={() => { attSlugError = '' }}
|
||||
onkeydown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') cancelAdd() }}
|
||||
placeholder="slug-atributo"
|
||||
class="field-input text-xs font-mono flex-1"
|
||||
/>
|
||||
</div>
|
||||
{#if attSlugError}
|
||||
<p class="text-[10px] text-red-400">{attSlugError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-1">
|
||||
<button onclick={confirmAdd}
|
||||
class="flex-1 px-2 py-1 text-xs rounded bg-indigo-600 hover:bg-indigo-500 text-white transition-colors">
|
||||
Añadir
|
||||
</button>
|
||||
<button onclick={cancelAdd}
|
||||
class="px-2 py-1 text-xs rounded border border-gray-700 text-gray-500 hover:text-gray-300 transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
{#if attId}
|
||||
<AttDetail />
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
|
||||
Selecciona un atributo para editarlo
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@ -1,33 +1,350 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
import type { ObjID, SecID } from '@/vcen/lib'
|
||||
|
||||
const editor = getContext<CatalogEditorStore>('catalog-editor')
|
||||
const cat = $derived(editor.cat)
|
||||
const sel = $derived(editor.selection)
|
||||
|
||||
const nav = [
|
||||
{ panel: 'cat', label: 'Catálogo', icon: '⬡' },
|
||||
{ panel: 'objs', label: 'Objetos', icon: '▣' },
|
||||
{ panel: 'opts', label: 'Opciones', icon: '◈' },
|
||||
] as const
|
||||
// Open/closed state for objects and sections
|
||||
let openObjs = $state<Set<ObjID>>(new Set())
|
||||
let openSecs = $state<Set<SecID>>(new Set())
|
||||
|
||||
// Obj creation form
|
||||
let creatingObj = $state(false)
|
||||
let newObjName = $state('')
|
||||
let newObjSlug = $state('')
|
||||
let objSlugError = $state('')
|
||||
|
||||
// Sec creation (keyed by objId)
|
||||
let creatingSecFor = $state<ObjID | null>(null)
|
||||
let newSecName = $state('')
|
||||
let newSecSlug = $state('')
|
||||
let secSlugError = $state('')
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text.toLowerCase()
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function confirmAddObj() {
|
||||
const slug = newObjSlug.trim()
|
||||
if (!slug) { objSlugError = 'El ID es obligatorio'; return }
|
||||
if (editor.isObjSlugTaken(slug)) { objSlugError = 'ID ya existe'; return }
|
||||
const id = editor.addObj(newObjName.trim() || 'Nuevo objeto', slug)
|
||||
openObjs = new Set([...openObjs, id])
|
||||
creatingObj = false; newObjName = ''; newObjSlug = ''; objSlugError = ''
|
||||
}
|
||||
function cancelAddObj() {
|
||||
creatingObj = false; newObjName = ''; newObjSlug = ''; objSlugError = ''
|
||||
}
|
||||
|
||||
function confirmAddSec(objId: ObjID) {
|
||||
const slug = newSecSlug.trim()
|
||||
if (!slug) { secSlugError = 'El ID es obligatorio'; return }
|
||||
if (editor.isSecSlugTaken(slug)) { secSlugError = 'ID ya existe'; return }
|
||||
const id = editor.addSec(objId, newSecName.trim() || 'Nueva sección', slug)
|
||||
openSecs = new Set([...openSecs, id])
|
||||
creatingSecFor = null; newSecName = ''; newSecSlug = ''; secSlugError = ''
|
||||
}
|
||||
function cancelAddSec() {
|
||||
creatingSecFor = null; newSecName = ''; newSecSlug = ''; secSlugError = ''
|
||||
}
|
||||
|
||||
function toggleObj(id: ObjID) {
|
||||
const n = new Set(openObjs)
|
||||
n.has(id) ? n.delete(id) : n.add(id)
|
||||
openObjs = n
|
||||
}
|
||||
function toggleSec(id: SecID) {
|
||||
const n = new Set(openSecs)
|
||||
n.has(id) ? n.delete(id) : n.add(id)
|
||||
openSecs = n
|
||||
}
|
||||
|
||||
function isActiveSel(kind: string, objId?: ObjID, secId?: SecID): boolean {
|
||||
const s = sel
|
||||
if (s.kind !== kind) return false
|
||||
if (objId && 'objId' in s && s.objId !== objId) return false
|
||||
if (secId && 'secId' in s && (s as any).secId !== secId) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function isNodeActive(kind: string, objId?: ObjID, secId?: SecID): boolean {
|
||||
return isActiveSel(kind, objId, secId)
|
||||
}
|
||||
|
||||
const ITEM_BASE = "flex items-center gap-1.5 px-3 py-1.5 text-xs cursor-pointer select-none transition-colors border-l-2"
|
||||
const ITEM_ACTIVE = "bg-indigo-600/10 text-indigo-300 border-indigo-500"
|
||||
const ITEM_INACTIVE = "text-gray-500 hover:text-gray-200 hover:bg-gray-800/50 border-transparent"
|
||||
</script>
|
||||
|
||||
<nav class="w-40 shrink-0 border-r border-gray-800 flex flex-col bg-gray-950 py-3 gap-0.5">
|
||||
<div class="px-4 pb-3 mb-1 border-b border-gray-800">
|
||||
<span class="text-xs font-bold tracking-widest text-indigo-400 uppercase">CAT</span>
|
||||
<span class="text-xs text-gray-700 ml-1">editor</span>
|
||||
<div class="py-2">
|
||||
|
||||
<!-- ── CATÁLOGO ──────────────────────────────────────────────── -->
|
||||
<div class="px-3 py-1.5 mb-0.5">
|
||||
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-700">Catálogo</span>
|
||||
</div>
|
||||
|
||||
<!-- Información general -->
|
||||
<button
|
||||
onclick={() => editor.select({ kind: 'cat-info' })}
|
||||
class="{ITEM_BASE} w-full pl-5 {isNodeActive('cat-info') ? ITEM_ACTIVE : ITEM_INACTIVE}"
|
||||
>
|
||||
<svg class="h-3 w-3 shrink-0" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="6" cy="6" r="4.5"/><path d="M6 5v3M6 4h.01"/>
|
||||
</svg>
|
||||
Información general
|
||||
</button>
|
||||
|
||||
<!-- Atributos del catálogo -->
|
||||
<button
|
||||
onclick={() => editor.select({ kind: 'cat-atts' })}
|
||||
class="{ITEM_BASE} w-full pl-5 justify-between {isNodeActive('cat-atts') ? ITEM_ACTIVE : ITEM_INACTIVE}"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="h-3 w-3 shrink-0" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M1 3h10M1 6h7M1 9h5"/>
|
||||
</svg>
|
||||
Atributos
|
||||
</span>
|
||||
{#if (cat.atts?.length ?? 0) > 0}
|
||||
<span class="text-[10px] px-1 rounded bg-gray-800 text-gray-600">{cat.atts?.length ?? 0}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Opciones -->
|
||||
<button
|
||||
onclick={() => editor.select({ kind: 'opts' })}
|
||||
class="{ITEM_BASE} w-full pl-5 justify-between {isNodeActive('opts') ? ITEM_ACTIVE : ITEM_INACTIVE}"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="h-3 w-3 shrink-0" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="3" cy="3" r="1.5"/><circle cx="3" cy="9" r="1.5"/>
|
||||
<circle cx="9" cy="3" r="1.5"/><circle cx="9" cy="9" r="1.5"/>
|
||||
</svg>
|
||||
Opciones
|
||||
</span>
|
||||
{#if cat.opts.length > 0}
|
||||
<span class="text-[10px] px-1 rounded bg-gray-800 text-gray-600">{cat.opts.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- ── OBJETOS ──────────────────────────────────────────────── -->
|
||||
<div class="px-3 pt-3 pb-1 mt-1 mb-0.5 flex items-center justify-between border-t border-gray-800/60">
|
||||
<span class="text-[9px] font-bold uppercase tracking-widest text-gray-700">Objetos</span>
|
||||
<button
|
||||
onclick={() => { creatingObj = true; newObjName = ''; newObjSlug = ''; objSlugError = '' }}
|
||||
class="text-gray-700 hover:text-indigo-400 transition-colors text-sm leading-none"
|
||||
title="Nuevo objeto"
|
||||
>+</button>
|
||||
</div>
|
||||
|
||||
{#if creatingObj}
|
||||
<div class="mx-3 mb-1 px-2 py-2 rounded border border-gray-700 bg-gray-900/80 flex flex-col gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newObjName}
|
||||
oninput={e => { if (!newObjSlug || newObjSlug === slugify(newObjName.slice(0,-1))) newObjSlug = slugify(e.currentTarget.value) }}
|
||||
placeholder="Nombre del objeto"
|
||||
class="field-input text-xs"
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-[10px] text-gray-600 shrink-0">ob:</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newObjSlug}
|
||||
oninput={() => { objSlugError = '' }}
|
||||
onkeydown={e => { if (e.key === 'Enter') confirmAddObj(); if (e.key === 'Escape') cancelAddObj() }}
|
||||
placeholder="slug-del-objeto"
|
||||
class="field-input text-xs font-mono flex-1"
|
||||
/>
|
||||
</div>
|
||||
{#if objSlugError}
|
||||
<p class="text-[10px] text-red-400">{objSlugError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-1">
|
||||
<button onclick={confirmAddObj}
|
||||
class="flex-1 px-2 py-1 text-xs rounded bg-indigo-600 hover:bg-indigo-500 text-white transition-colors">
|
||||
Crear
|
||||
</button>
|
||||
<button onclick={cancelAddObj}
|
||||
class="px-2 py-1 text-xs rounded border border-gray-700 text-gray-500 hover:text-gray-300 transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if cat.objs.length === 0}
|
||||
<div class="px-5 py-2 text-[11px] text-gray-700 italic">Sin objetos</div>
|
||||
{:else}
|
||||
{#each cat.objs as obj (obj.id)}
|
||||
{@const isObjOpen = openObjs.has(obj.id)}
|
||||
{@const objAttCount = obj.atts.length}
|
||||
{@const secCount = obj.secs.length}
|
||||
|
||||
<!-- Obj row -->
|
||||
<div
|
||||
role="button" tabindex="0"
|
||||
onclick={() => toggleObj(obj.id)}
|
||||
onkeydown={e => e.key === 'Enter' && toggleObj(obj.id)}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs cursor-pointer
|
||||
select-none transition-colors text-gray-400 hover:text-gray-200 hover:bg-gray-800/40
|
||||
border-l-2 border-transparent group"
|
||||
>
|
||||
<svg
|
||||
class="h-2.5 w-2.5 shrink-0 text-gray-600 transition-transform {isObjOpen ? 'rotate-90' : ''}"
|
||||
viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2"
|
||||
><path d="M3 2l4 3-4 3"/></svg>
|
||||
<svg class="h-3 w-3 shrink-0 text-gray-600" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="1" y="1" width="10" height="10" rx="2"/>
|
||||
</svg>
|
||||
<span class="flex-1 truncate font-medium">{obj.name || 'Sin nombre'}</span>
|
||||
<button
|
||||
onclick={e => { e.stopPropagation(); editor.removeObj(obj.id) }}
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-700 hover:text-red-400 transition-opacity ml-auto"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
{#each nav as item}
|
||||
{#if isObjOpen}
|
||||
<!-- obj subitems -->
|
||||
<button
|
||||
onclick={() => editor.setPanel(item.panel)}
|
||||
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-left transition-colors
|
||||
border-l-2
|
||||
{editor.activePanel === item.panel
|
||||
? 'bg-indigo-600/10 text-indigo-300 border-indigo-500'
|
||||
: 'text-gray-500 hover:text-gray-200 hover:bg-gray-800/50 border-transparent'}"
|
||||
onclick={() => editor.select({ kind: 'obj-info', objId: obj.id })}
|
||||
class="{ITEM_BASE} w-full pl-10 {isNodeActive('obj-info', obj.id) ? ITEM_ACTIVE : ITEM_INACTIVE}"
|
||||
>
|
||||
<span class="text-base leading-none">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
<svg class="h-3 w-3 shrink-0 text-gray-700" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="6" cy="6" r="4.5"/><path d="M6 5v3M6 4h.01"/>
|
||||
</svg>
|
||||
General
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => editor.select({ kind: 'obj-atts', objId: obj.id })}
|
||||
class="{ITEM_BASE} w-full pl-10 justify-between {isNodeActive('obj-atts', obj.id) ? ITEM_ACTIVE : ITEM_INACTIVE}"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="h-3 w-3 shrink-0 text-gray-700" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M1 3h10M1 6h7M1 9h5"/>
|
||||
</svg>
|
||||
Atributos
|
||||
</span>
|
||||
{#if objAttCount > 0}
|
||||
<span class="text-[10px] px-1 rounded bg-gray-800 text-gray-600">{objAttCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Secciones sub-tree -->
|
||||
<div class="flex items-center pl-7 pr-3 py-1 border-l-2 border-transparent">
|
||||
<button
|
||||
onclick={() => {}}
|
||||
class="flex items-center gap-1.5 text-xs text-gray-600 flex-1 select-none"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5 shrink-0" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 2l4 3-4 3"/>
|
||||
</svg>
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wider text-gray-700">Secciones</span>
|
||||
{#if secCount > 0}
|
||||
<span class="text-[10px] px-1 rounded bg-gray-800 text-gray-600">{secCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { creatingSecFor = obj.id; newSecName = ''; newSecSlug = ''; secSlugError = '' }}
|
||||
class="text-gray-700 hover:text-indigo-400 transition-colors text-sm leading-none"
|
||||
title="Nueva sección"
|
||||
>+</button>
|
||||
</div>
|
||||
|
||||
{#if creatingSecFor === obj.id}
|
||||
<div class="mx-3 mb-1 px-2 py-2 rounded border border-gray-700 bg-gray-900/80 flex flex-col gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newSecName}
|
||||
oninput={e => { if (!newSecSlug || newSecSlug === slugify(newSecName.slice(0,-1))) newSecSlug = slugify(e.currentTarget.value) }}
|
||||
placeholder="Nombre de la sección"
|
||||
class="field-input text-xs"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-[10px] text-gray-600 shrink-0">sc:</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newSecSlug}
|
||||
oninput={() => { secSlugError = '' }}
|
||||
onkeydown={e => { if (e.key === 'Enter') confirmAddSec(obj.id); if (e.key === 'Escape') cancelAddSec() }}
|
||||
placeholder="slug-seccion"
|
||||
class="field-input text-xs font-mono flex-1"
|
||||
/>
|
||||
</div>
|
||||
{#if secSlugError}
|
||||
<p class="text-[10px] text-red-400">{secSlugError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-1">
|
||||
<button onclick={() => confirmAddSec(obj.id)}
|
||||
class="flex-1 px-2 py-1 text-xs rounded bg-indigo-600 hover:bg-indigo-500 text-white transition-colors">
|
||||
Crear
|
||||
</button>
|
||||
<button onclick={cancelAddSec}
|
||||
class="px-2 py-1 text-xs rounded border border-gray-700 text-gray-500 hover:text-gray-300 transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each obj.secs as sec (sec.id)}
|
||||
{@const isSecOpen = openSecs.has(sec.id)}
|
||||
{@const secAttCount = sec.atts.length}
|
||||
|
||||
<!-- Sec row -->
|
||||
<div
|
||||
role="button" tabindex="0"
|
||||
onclick={() => toggleSec(sec.id)}
|
||||
onkeydown={e => e.key === 'Enter' && toggleSec(sec.id)}
|
||||
class="flex items-center gap-1.5 pl-9 pr-3 py-1 text-xs cursor-pointer
|
||||
select-none transition-colors text-gray-500 hover:text-gray-200 hover:bg-gray-800/40
|
||||
border-l-2 border-transparent group"
|
||||
>
|
||||
<svg
|
||||
class="h-2 w-2 shrink-0 text-gray-700 transition-transform {isSecOpen ? 'rotate-90' : ''}"
|
||||
viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2"
|
||||
><path d="M3 2l4 3-4 3"/></svg>
|
||||
<span class="flex-1 truncate">{sec.name || 'Sin nombre'}</span>
|
||||
<button
|
||||
onclick={e => { e.stopPropagation(); editor.removeSec(obj.id, sec.id) }}
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-700 hover:text-red-400 transition-opacity ml-auto"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
{#if isSecOpen}
|
||||
<button
|
||||
onclick={() => editor.select({ kind: 'sec-info', objId: obj.id, secId: sec.id })}
|
||||
class="{ITEM_BASE} w-full pl-[3.75rem] {isNodeActive('sec-info', obj.id, sec.id) ? ITEM_ACTIVE : ITEM_INACTIVE}"
|
||||
>
|
||||
<svg class="h-3 w-3 shrink-0 text-gray-700" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="6" cy="6" r="4.5"/><path d="M6 5v3M6 4h.01"/>
|
||||
</svg>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor.select({ kind: 'sec-atts', objId: obj.id, secId: sec.id })}
|
||||
class="{ITEM_BASE} w-full pl-[3.75rem] justify-between {isNodeActive('sec-atts', obj.id, sec.id) ? ITEM_ACTIVE : ITEM_INACTIVE}"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="h-3 w-3 shrink-0 text-gray-700" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M1 3h10M1 6h7M1 9h5"/>
|
||||
</svg>
|
||||
Atributos
|
||||
</span>
|
||||
{#if secAttCount > 0}
|
||||
<span class="text-[10px] px-1 rounded bg-gray-800 text-gray-600">{secAttCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
import AttDetail from './AttDetail.svelte'
|
||||
|
||||
const editor = getContext<CatalogEditorStore>('catalog-editor')
|
||||
const sel = $derived(editor.selection)
|
||||
const ctx = $derived(sel.kind === 'obj-atts'
|
||||
? { objId: sel.objId, obj: editor.findObj(sel.objId), attId: sel.attId }
|
||||
: null)
|
||||
|
||||
const domainBadge: Record<string, string> = {
|
||||
string: 'bg-blue-900/40 text-blue-400',
|
||||
number: 'bg-green-900/40 text-green-400',
|
||||
currency: 'bg-emerald-900/40 text-emerald-400',
|
||||
boolean: 'bg-purple-900/40 text-purple-400',
|
||||
option: 'bg-amber-900/40 text-amber-400',
|
||||
date: 'bg-rose-900/40 text-rose-400',
|
||||
time: 'bg-rose-900/40 text-rose-400',
|
||||
datetime: 'bg-rose-900/40 text-rose-400',
|
||||
}
|
||||
|
||||
let creating = $state(false)
|
||||
let newAttName = $state('')
|
||||
let newAttSlug = $state('')
|
||||
let attSlugError = $state('')
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text.toLowerCase()
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function confirmAdd() {
|
||||
if (!ctx?.obj) return
|
||||
const slug = newAttSlug.trim()
|
||||
if (!slug) { attSlugError = 'El ID es obligatorio'; return }
|
||||
if (editor.isAttSlugTaken(slug)) { attSlugError = 'ID ya existe'; return }
|
||||
editor.addAtt(ctx.objId, undefined, newAttName.trim() || 'Atributo', slug)
|
||||
creating = false; newAttName = ''; newAttSlug = ''; attSlugError = ''
|
||||
}
|
||||
function cancelAdd() {
|
||||
creating = false; newAttName = ''; newAttSlug = ''; attSlugError = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if ctx?.obj}
|
||||
{@const { objId, obj, attId } = ctx}
|
||||
<div class="flex h-full">
|
||||
<!-- Att list -->
|
||||
<div class="w-56 shrink-0 border-r border-gray-800 flex flex-col">
|
||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800 shrink-0">
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Atributos ({obj.atts.length})
|
||||
</span>
|
||||
<button
|
||||
onclick={() => { creating = true; newAttName = '' }}
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-gray-500
|
||||
hover:text-indigo-400 hover:bg-indigo-500/10 transition-colors"
|
||||
>+</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto py-1">
|
||||
{#if obj.atts.length === 0}
|
||||
<div class="px-4 py-6 text-center text-gray-700 text-xs italic">
|
||||
Sin atributos
|
||||
<button
|
||||
onclick={() => { creating = true; newAttName = '' }}
|
||||
class="block mx-auto mt-2 px-3 py-1 rounded border border-dashed border-gray-700
|
||||
text-gray-600 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
|
||||
>Añadir atributo</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#each obj.atts as att (att.id)}
|
||||
{@const isActive = attId === att.id}
|
||||
<div
|
||||
role="button" tabindex="0"
|
||||
onclick={() => editor.select({ kind: 'obj-atts', objId, attId: att.id })}
|
||||
onkeydown={e => e.key === 'Enter' && editor.select({ kind: 'obj-atts', objId, attId: att.id })}
|
||||
class="px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer
|
||||
transition-colors group border-l-2
|
||||
{isActive
|
||||
? 'bg-indigo-600/20 text-indigo-300 border-indigo-500'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}"
|
||||
>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded shrink-0
|
||||
{domainBadge[att.value.domain.kind] ?? 'bg-gray-800 text-gray-500'}">
|
||||
{att.value.domain.kind.slice(0, 3)}
|
||||
</span>
|
||||
<span class="flex-1 truncate">
|
||||
{#if att.name}{att.name}{:else}<span class="italic text-gray-600">Sin nombre</span>{/if}
|
||||
</span>
|
||||
{#if att.rules?.rules?.length}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" title="Tiene reglas"></span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={e => { e.stopPropagation(); editor.removeAtt(objId, undefined, att.id) }}
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-opacity text-xs"
|
||||
>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if creating}
|
||||
<div class="px-3 py-2 border-t border-gray-800 bg-gray-900/60 flex flex-col gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newAttName}
|
||||
oninput={e => { if (!newAttSlug || newAttSlug === slugify(newAttName.slice(0,-1))) newAttSlug = slugify(e.currentTarget.value) }}
|
||||
placeholder="Nombre del atributo"
|
||||
class="field-input text-xs"
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-[10px] text-gray-600 shrink-0">at:</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newAttSlug}
|
||||
oninput={() => { attSlugError = '' }}
|
||||
onkeydown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') cancelAdd() }}
|
||||
placeholder="slug-atributo"
|
||||
class="field-input text-xs font-mono flex-1"
|
||||
/>
|
||||
</div>
|
||||
{#if attSlugError}
|
||||
<p class="text-[10px] text-red-400">{attSlugError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-1">
|
||||
<button onclick={confirmAdd}
|
||||
class="flex-1 px-2 py-1 text-xs rounded bg-indigo-600 hover:bg-indigo-500 text-white transition-colors">
|
||||
Añadir
|
||||
</button>
|
||||
<button onclick={cancelAdd}
|
||||
class="px-2 py-1 text-xs rounded border border-gray-700 text-gray-500 hover:text-gray-300 transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Att detail -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
{#if attId}
|
||||
<AttDetail />
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
|
||||
Selecciona un atributo para editarlo
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
import RuleList from './RuleList.svelte'
|
||||
import { buildEvalCtx, evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts'
|
||||
|
||||
const editor = getContext<CatalogEditorStore>('catalog-editor')
|
||||
const sel = $derived(editor.selection)
|
||||
const obj = $derived(sel.kind === 'obj-info' ? editor.findObj(sel.objId) ?? null : null)
|
||||
const objId = $derived(sel.kind === 'obj-info' ? sel.objId : null)
|
||||
|
||||
const computedState = $derived.by(() => {
|
||||
if (!obj) return null
|
||||
const ctx = buildEvalCtx(editor.cat, obj)
|
||||
return evalRuleSetState(obj.rules, ctx)
|
||||
})
|
||||
|
||||
const stateBadge: Record<string, string> = {
|
||||
available: 'bg-green-900/40 text-green-400 border-green-900/60',
|
||||
required: 'bg-blue-900/40 text-blue-400 border-blue-900/60',
|
||||
hidden: 'bg-gray-800 text-gray-500 border-gray-700',
|
||||
forbidden: 'bg-red-900/40 text-red-400 border-red-900/60',
|
||||
}
|
||||
const stateLabel: Record<string, string> = {
|
||||
available: 'Disponible', required: 'Requerido',
|
||||
hidden: 'Oculto', forbidden: 'Prohibido',
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if obj && objId}
|
||||
<div class="p-6 max-w-2xl flex flex-col gap-6 overflow-y-auto h-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-200">{obj.name || 'Sin nombre'}</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if computedState}
|
||||
<span class="text-xs px-2 py-0.5 rounded border font-medium {stateBadge[computedState] ?? ''}">
|
||||
{stateLabel[computedState] ?? computedState}
|
||||
</span>
|
||||
{:else if obj.rules?.rules?.length}
|
||||
<span class="text-xs text-gray-700 italic">sin coincidencia → Disponible</span>
|
||||
{/if}
|
||||
<span class="text-xs text-gray-700 font-mono">{obj.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{#each [
|
||||
{ label: 'Atributos directos', value: obj.atts.length },
|
||||
{ label: 'Secciones', value: obj.secs.length },
|
||||
{ label: 'Reglas', value: obj.rules?.rules?.length ?? 0 },
|
||||
] as s}
|
||||
<div class="flex flex-col items-center gap-1 p-3 rounded-lg bg-gray-900 border border-gray-800">
|
||||
<span class="text-xl font-bold text-indigo-400">{s.value}</span>
|
||||
<span class="text-xs text-gray-600">{s.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Nombre -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="section-title">Identificación</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">Nombre</label>
|
||||
<input type="text" value={obj.name}
|
||||
oninput={e => editor.renameObj(objId, e.currentTarget.value)}
|
||||
placeholder="Habitación, Cocina..."
|
||||
class="field-input" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">ID (solo lectura)</label>
|
||||
<input type="text" value={obj.id} readonly
|
||||
class="field-input font-mono text-xs text-gray-500 cursor-default" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Reglas -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between section-title" style="margin-bottom:0">
|
||||
<span>Reglas <span class="text-gray-700 font-normal normal-case">({obj.rules?.rules?.length ?? 0})</span></span>
|
||||
{#if obj.rules}
|
||||
<button onclick={() => editor.setObjRuleSet(objId, undefined)}
|
||||
class="text-xs text-red-600 hover:text-red-400 transition-colors normal-case font-normal">
|
||||
Eliminar todas
|
||||
</button>
|
||||
{:else}
|
||||
<button onclick={() => editor.setObjRuleSet(objId, { rules: [] })}
|
||||
class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors normal-case font-normal">
|
||||
+ Añadir reglas
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if obj.rules}
|
||||
<RuleList ruleSet={obj.rules} container={obj} catOpts={editor.cat.opts} />
|
||||
{:else}
|
||||
<p class="text-xs text-gray-700 italic">Sin reglas</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
import AttDetail from './AttDetail.svelte'
|
||||
|
||||
const editor = getContext<CatalogEditorStore>('catalog-editor')
|
||||
const sel = $derived(editor.selection)
|
||||
const ctx = $derived(sel.kind === 'sec-atts'
|
||||
? { objId: sel.objId, secId: sel.secId, sec: editor.findSec(sel.objId, sel.secId), attId: sel.attId }
|
||||
: null)
|
||||
|
||||
const domainBadge: Record<string, string> = {
|
||||
string: 'bg-blue-900/40 text-blue-400',
|
||||
number: 'bg-green-900/40 text-green-400',
|
||||
currency: 'bg-emerald-900/40 text-emerald-400',
|
||||
boolean: 'bg-purple-900/40 text-purple-400',
|
||||
option: 'bg-amber-900/40 text-amber-400',
|
||||
date: 'bg-rose-900/40 text-rose-400',
|
||||
time: 'bg-rose-900/40 text-rose-400',
|
||||
datetime: 'bg-rose-900/40 text-rose-400',
|
||||
}
|
||||
|
||||
let creating = $state(false)
|
||||
let newAttName = $state('')
|
||||
let newAttSlug = $state('')
|
||||
let attSlugError = $state('')
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text.toLowerCase()
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function confirmAdd() {
|
||||
if (!ctx?.sec) return
|
||||
const slug = newAttSlug.trim()
|
||||
if (!slug) { attSlugError = 'El ID es obligatorio'; return }
|
||||
if (editor.isAttSlugTaken(slug)) { attSlugError = 'ID ya existe'; return }
|
||||
editor.addAtt(ctx.objId, ctx.secId, newAttName.trim() || 'Atributo', slug)
|
||||
creating = false; newAttName = ''; newAttSlug = ''; attSlugError = ''
|
||||
}
|
||||
function cancelAdd() {
|
||||
creating = false; newAttName = ''; newAttSlug = ''; attSlugError = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if ctx?.sec}
|
||||
{@const { objId, secId, sec, attId } = ctx}
|
||||
<div class="flex h-full">
|
||||
<div class="w-56 shrink-0 border-r border-gray-800 flex flex-col">
|
||||
<!-- sec name header -->
|
||||
<div class="px-4 py-2.5 border-b border-gray-800 shrink-0">
|
||||
<input type="text" value={sec.name}
|
||||
oninput={e => editor.renameSec(objId, secId, e.currentTarget.value)}
|
||||
placeholder="Nombre sección" class="field-input text-xs" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-800 shrink-0">
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Atributos ({sec.atts.length})
|
||||
</span>
|
||||
<button onclick={() => { creating = true; newAttName = '' }}
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-gray-500
|
||||
hover:text-indigo-400 hover:bg-indigo-500/10 transition-colors">+</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto py-1">
|
||||
{#if sec.atts.length === 0}
|
||||
<div class="px-4 py-6 text-center text-gray-700 text-xs italic">
|
||||
Sin atributos
|
||||
<button onclick={() => { creating = true; newAttName = '' }}
|
||||
class="block mx-auto mt-2 px-3 py-1 rounded border border-dashed border-gray-700
|
||||
text-gray-600 hover:text-indigo-400 hover:border-indigo-700 transition-colors">
|
||||
Añadir atributo
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#each sec.atts as att (att.id)}
|
||||
{@const isActive = attId === att.id}
|
||||
<div
|
||||
role="button" tabindex="0"
|
||||
onclick={() => editor.select({ kind: 'sec-atts', objId, secId, attId: att.id })}
|
||||
onkeydown={e => e.key === 'Enter' && editor.select({ kind: 'sec-atts', objId, secId, attId: att.id })}
|
||||
class="px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer
|
||||
transition-colors group border-l-2
|
||||
{isActive
|
||||
? 'bg-indigo-600/20 text-indigo-300 border-indigo-500'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}"
|
||||
>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded shrink-0
|
||||
{domainBadge[att.value.domain.kind] ?? 'bg-gray-800 text-gray-500'}">
|
||||
{att.value.domain.kind.slice(0, 3)}
|
||||
</span>
|
||||
<span class="flex-1 truncate">
|
||||
{#if att.name}{att.name}{:else}<span class="italic text-gray-600">Sin nombre</span>{/if}
|
||||
</span>
|
||||
{#if att.rules?.rules?.length}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0"></span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={e => { e.stopPropagation(); editor.removeAtt(objId, secId, att.id) }}
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-opacity text-xs"
|
||||
>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if creating}
|
||||
<div class="px-3 py-2 border-t border-gray-800 bg-gray-900/60 flex flex-col gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newAttName}
|
||||
oninput={e => { if (!newAttSlug || newAttSlug === slugify(newAttName.slice(0,-1))) newAttSlug = slugify(e.currentTarget.value) }}
|
||||
placeholder="Nombre del atributo"
|
||||
class="field-input text-xs"
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-[10px] text-gray-600 shrink-0">at:</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newAttSlug}
|
||||
oninput={() => { attSlugError = '' }}
|
||||
onkeydown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') cancelAdd() }}
|
||||
placeholder="slug-atributo"
|
||||
class="field-input text-xs font-mono flex-1"
|
||||
/>
|
||||
</div>
|
||||
{#if attSlugError}
|
||||
<p class="text-[10px] text-red-400">{attSlugError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-1">
|
||||
<button onclick={confirmAdd}
|
||||
class="flex-1 px-2 py-1 text-xs rounded bg-indigo-600 hover:bg-indigo-500 text-white transition-colors">
|
||||
Añadir
|
||||
</button>
|
||||
<button onclick={cancelAdd}
|
||||
class="px-2 py-1 text-xs rounded border border-gray-700 text-gray-500 hover:text-gray-300 transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
{#if attId}
|
||||
<AttDetail />
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
|
||||
Selecciona un atributo para editarlo
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
import RuleList from './RuleList.svelte'
|
||||
|
||||
const editor = getContext<CatalogEditorStore>('catalog-editor')
|
||||
const sel = $derived(editor.selection)
|
||||
const ctx = $derived(sel.kind === 'sec-info'
|
||||
? { objId: sel.objId, secId: sel.secId, sec: editor.findSec(sel.objId, sel.secId) ?? null }
|
||||
: null)
|
||||
</script>
|
||||
|
||||
{#if ctx?.sec}
|
||||
{@const { objId, secId, sec } = ctx}
|
||||
<div class="p-6 max-w-2xl flex flex-col gap-6 overflow-y-auto h-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-200">{sec.name || 'Sin nombre'}</h2>
|
||||
<span class="text-xs text-gray-700 font-mono">{sec.id}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{#each [
|
||||
{ label: 'Atributos', value: sec.atts.length },
|
||||
{ label: 'Reglas', value: sec.rules?.rules?.length ?? 0 },
|
||||
] as s}
|
||||
<div class="flex flex-col items-center gap-1 p-3 rounded-lg bg-gray-900 border border-gray-800">
|
||||
<span class="text-xl font-bold text-indigo-400">{s.value}</span>
|
||||
<span class="text-xs text-gray-600">{s.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="section-title">Identificación</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">Nombre</label>
|
||||
<input type="text" value={sec.name}
|
||||
oninput={e => editor.renameSec(objId, secId, e.currentTarget.value)}
|
||||
placeholder="Suelo, Paredes..."
|
||||
class="field-input" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">ID (solo lectura)</label>
|
||||
<input type="text" value={sec.id} readonly
|
||||
class="field-input font-mono text-xs text-gray-500 cursor-default" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between section-title" style="margin-bottom:0">
|
||||
<span>Reglas <span class="text-gray-700 font-normal normal-case">({sec.rules?.rules?.length ?? 0})</span></span>
|
||||
{#if sec.rules}
|
||||
<button onclick={() => editor.setSecRuleSet(objId, secId, undefined)}
|
||||
class="text-xs text-red-600 hover:text-red-400 transition-colors normal-case font-normal">
|
||||
Eliminar todas
|
||||
</button>
|
||||
{:else}
|
||||
<button onclick={() => editor.setSecRuleSet(objId, secId, { rules: [] })}
|
||||
class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors normal-case font-normal">
|
||||
+ Añadir reglas
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if sec.rules}
|
||||
<RuleList ruleSet={sec.rules} container={sec} catOpts={editor.cat.opts} />
|
||||
{:else}
|
||||
<p class="text-xs text-gray-700 italic">Sin reglas</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Utilidades para evaluar reglas contra valores por defecto del catálogo.
|
||||
* Permite mostrar el estado calculado de un objeto/atributo en el editor.
|
||||
*/
|
||||
import type { Cat, RuleSet, AttState, Obj, Sec } from '@/vcen/lib'
|
||||
import { JsonLogicEngine } from '@/jslg/lib'
|
||||
|
||||
const _engine = new JsonLogicEngine()
|
||||
|
||||
/**
|
||||
* Construye el contexto de datos para evaluar condiciones JsonLogic.
|
||||
* Usa los valores por defecto de cada atributo como valores actuales.
|
||||
*
|
||||
* Rutas accesibles:
|
||||
* cat/<attId> → atributo de catálogo
|
||||
* <attId> → atributo directo del contenedor
|
||||
* <secId>/<attId> → atributo de sección (solo si container es Obj)
|
||||
*/
|
||||
export function buildEvalCtx(cat: Cat, container: Obj | Sec): Record<string, any> {
|
||||
const ctx: Record<string, any> = {}
|
||||
|
||||
ctx['cat'] = {}
|
||||
for (const att of cat.atts ?? []) {
|
||||
ctx['cat'][att.id] = att.value.value
|
||||
}
|
||||
|
||||
for (const att of container.atts) {
|
||||
ctx[att.id] = att.value.value
|
||||
}
|
||||
|
||||
if ('secs' in container) {
|
||||
for (const sec of (container as Obj).secs) {
|
||||
ctx[sec.id] = {}
|
||||
for (const att of sec.atts) {
|
||||
ctx[sec.id][att.id] = att.value.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Evalúa un RuleSet y retorna el AttState que gana (primera regla activa), o null. */
|
||||
export function evalRuleSetState(
|
||||
ruleSet: RuleSet | undefined,
|
||||
ctx: Record<string, any>,
|
||||
): AttState | null {
|
||||
if (!ruleSet?.rules.length) return null
|
||||
|
||||
// Mayor prioridad primero (número mayor = se evalúa antes)
|
||||
const sorted = [...ruleSet.rules].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||
|
||||
for (const rule of sorted) {
|
||||
let fires: boolean
|
||||
try {
|
||||
fires = rule.condition === true
|
||||
? true
|
||||
: Boolean(_engine.evaluate(rule.condition as any, ctx))
|
||||
} catch { continue }
|
||||
|
||||
if (!fires) continue
|
||||
|
||||
for (const action of rule.actions) {
|
||||
if (action.type === 'attribute' && (!action.target || action.target.length === 0)) {
|
||||
return action.ifTrue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Loading…
Reference in new issue