feat(vcen-edit): rediseño UX del editor de reglas y catálogo

- 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
dev 7 months ago
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>
</main>
<div class="h-screen w-screen overflow-hidden">
<CatalogEditor cat={cat} onsave={onSave} />
</div>

@ -4,26 +4,39 @@
import CatalogEditor from "@/vcen-edit/comps/CatalogEditor.svelte";
const cat = {
id: toCatID('test'),
name: 'Nuevo',
optCategories: [],
opts: [],
atts: [],
objs: [],
const STORAGE_KEY = 'vcen-editor-cat'
function loadCat(): Cat {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) return JSON.parse(stored)
} catch {}
return {
id: toCatID('test'),
name: 'Nuevo',
optCategories: [],
opts: [],
atts: [],
objs: [],
}
}
function saveCat(cat: Cat) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cat))
} catch {}
}
const cat = loadCat()
</script>
<div class="h-full">
<CatalogEditor cat={cat} />
<CatalogEditor {cat} onsave={saveCat} />
</div>
<style>
</style>
</style>

@ -1,29 +1,50 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
import type { AttDomain } from '@/vcen/lib'
import type { AttDomain, ObjID, SecID, AttID } from '@/vcen/lib'
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 ctx = $derived.by(() => {
if (sel.kind !== 'att') return null
const { objId, secId, attId } = sel
const att = editor.findAtt(objId, secId, attId)
if (!att) return null
const container = secId
? editor.findSec(objId, secId) ?? null
: editor.findObj(objId) ?? null
return { att, objId, secId, attId, container }
const s = sel
if (s.kind === 'cat-atts' && s.attId) {
const att = editor.findCatAtt(s.attId)
if (!att) return null
return { att, isCatLevel: true, objId: undefined as ObjID | undefined, secId: undefined as SecID | undefined, attId: s.attId, container: null }
} else if (s.kind === 'obj-atts' && s.attId) {
const objId = s.objId
const attId = s.attId
const att = editor.findAtt(objId, undefined, attId)
if (!att) return null
return { att, isCatLevel: false, objId, secId: undefined as SecID | undefined, attId, container: editor.findObj(objId) ?? null }
} else if (s.kind === 'sec-atts' && s.attId) {
const objId = s.objId
const secId = s.secId
const attId = s.attId
const att = editor.findAtt(objId, secId, attId)
if (!att) return null
return { att, isCatLevel: false, objId, secId, attId, container: editor.findSec(objId, secId) ?? null }
}
return null
})
function patch(key: string, value: unknown) {
if (!ctx) return
if (key === 'name') editor.renameAtt(ctx.objId, ctx.secId, ctx.attId, value as string)
else if (key === 'domain') editor.updateAttDomain(ctx.objId, ctx.secId, ctx.attId, value as AttDomain)
else if (key === 'default') editor.updateAttDefaultValue(ctx.objId, ctx.secId, ctx.attId, value)
const { isCatLevel, objId, secId, attId } = ctx
if (isCatLevel) {
if (key === 'name') editor.renameCatAtt(attId, value as string)
else if (key === 'domain') editor.updateCatAttDomain(attId, value as AttDomain)
else if (key === 'default') editor.updateCatAttDefaultValue(attId, value)
} else {
if (key === 'name') editor.renameAtt(objId!, secId, attId, value as string)
else if (key === 'domain') editor.updateAttDomain(objId!, secId, attId, value as AttDomain)
else if (key === 'default') editor.updateAttDefaultValue(objId!, secId, attId, value)
}
}
// Domain kind change
@ -40,7 +61,7 @@ const DOMAIN_KINDS = [
function onDomainKindChange(kind: string) {
if (!ctx) return
editor.updateAttDomain(ctx.objId, ctx.secId, ctx.attId, defaultDomain(kind as AttDomain['kind']))
patch('domain', defaultDomain(kind as AttDomain['kind']))
}
function defaultDomain(kind: AttDomain['kind']): AttDomain {
@ -61,25 +82,76 @@ const attOptIds = $derived.by(() => {
function toggleOpt(optId: string) {
if (!ctx) return
attOptIds.has(optId)
? editor.removeAttOpt(ctx.objId, ctx.secId, ctx.attId, optId as any)
: editor.addAttOpt(ctx.objId, ctx.secId, ctx.attId, optId as any)
const { isCatLevel, objId, secId, attId } = ctx
if (isCatLevel) {
attOptIds.has(optId)
? editor.removeCatAttOpt(attId, optId as any)
: editor.addCatAttOpt(attId, optId as any)
} else {
attOptIds.has(optId)
? editor.removeAttOpt(objId!, secId, attId, optId as any)
: editor.addAttOpt(objId!, secId, attId, optId as any)
}
}
function updateOptLabel(optId: string, label: string) {
if (!ctx) return
const { isCatLevel, objId, secId, attId } = ctx
if (isCatLevel) {
editor.updateCatAttOptLabel(attId, optId as any, label)
} else {
editor.updateAttOptLabel(objId!, secId, attId, optId as any, label)
}
}
// Rules
function ensureRules() {
if (!ctx || ctx.att.rules) return
editor.setAttRuleSet(ctx.objId, ctx.secId, ctx.attId, { rules: [] })
if (!ctx) return
if (ctx.isCatLevel) {
editor.setCatAttRuleSet(ctx.attId, { rules: [] })
} else {
editor.setAttRuleSet(ctx.objId!, ctx.secId, ctx.attId, { rules: [] })
}
}
function clearRules() {
if (!ctx) return
editor.setAttRuleSet(ctx.objId, ctx.secId, ctx.attId, undefined)
if (ctx.isCatLevel) {
editor.setCatAttRuleSet(ctx.attId, undefined)
} else {
editor.setAttRuleSet(ctx.objId!, ctx.secId, ctx.attId, undefined)
}
}
// For updateAttDomain in template — need to handle cat level
function updateDomain(domain: AttDomain) {
patch('domain', domain)
}
// Computed state from rules
const computedState = $derived.by(() => {
if (!ctx?.att.rules) return null
// For cat-level atts, use the cat itself as container proxy
const container = ctx.container ?? (ctx.isCatLevel ? { atts: editor.cat.atts ?? [], id: editor.cat.id } as any : null)
if (!container) return null
const evalCtx = buildEvalCtx(editor.cat, container)
return evalRuleSetState(ctx.att.rules, evalCtx)
})
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 ctx}
{@const { att, objId, secId, attId, container } = ctx}
{@const { att, isCatLevel, objId, secId, attId, container } = ctx}
<div class="p-6 max-w-2xl flex flex-col gap-6">
@ -88,7 +160,16 @@ function clearRules() {
<h2 class="text-sm font-semibold text-gray-200">
{att.name || 'Sin nombre'}
</h2>
<span class="text-xs text-gray-700 font-mono">{att.id}</span>
<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 att.rules?.rules?.length}
<span class="text-xs text-gray-700 italic">→ Disponible</span>
{/if}
<span class="text-xs text-gray-700 font-mono">{att.id}</span>
</div>
</div>
<!-- Identificación -->
@ -127,8 +208,7 @@ function clearRules() {
<div class="flex gap-2">
{#each ['input', 'list', 'pattern', 'fixed'] as mode}
<button
onclick={() => editor.updateAttDomain(objId, secId, attId,
{ kind: 'string', mode: mode as any })}
onclick={() => updateDomain({ kind: 'string', mode: mode as any })}
class="px-2.5 py-1 text-xs rounded border transition-colors
{d.mode === mode
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
@ -139,7 +219,7 @@ function clearRules() {
{#if d.mode === 'list'}
<textarea
value={(d.list ?? []).join('\n')}
oninput={e => editor.updateAttDomain(objId, secId, attId,
oninput={e => updateDomain(
{ kind: 'string', mode: 'list',
list: e.currentTarget.value.split('\n').map((s:string) => s.trim()).filter(Boolean) })}
placeholder="uno por línea"
@ -147,12 +227,12 @@ function clearRules() {
></textarea>
{:else if d.mode === 'pattern'}
<input type="text" value={d.pattern ?? ''}
oninput={e => editor.updateAttDomain(objId, secId, attId,
oninput={e => updateDomain(
{ kind: 'string', mode: 'pattern', pattern: e.currentTarget.value })}
placeholder="^[A-Z]{2}[0-9]+$" class="field-input" />
{:else if d.mode === 'fixed'}
<input type="text" value={d.fixed ?? ''}
oninput={e => editor.updateAttDomain(objId, secId, attId,
oninput={e => updateDomain(
{ kind: 'string', mode: 'fixed', fixed: e.currentTarget.value })}
class="field-input" />
{/if}
@ -165,7 +245,7 @@ function clearRules() {
<div class="flex gap-2 flex-wrap">
{#each ['input', 'range', 'step', 'list', 'fixed'] as mode}
<button
onclick={() => editor.updateAttDomain(objId, secId, attId,
onclick={() => updateDomain(
{ kind: att.value.domain.kind, mode: mode as any } as any)}
class="px-2.5 py-1 text-xs rounded border transition-colors
{d.mode === mode
@ -179,68 +259,123 @@ function clearRules() {
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Mín</span>
<input type="number" value={d.min ?? 0}
oninput={e => editor.updateAttDomain(objId, secId, attId, { ...d, min: Number(e.currentTarget.value) })}
oninput={e => updateDomain({ ...d, min: Number(e.currentTarget.value) })}
class="field-input" />
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Máx</span>
<input type="number" value={d.max ?? 100}
oninput={e => editor.updateAttDomain(objId, secId, attId, { ...d, max: Number(e.currentTarget.value) })}
oninput={e => updateDomain({ ...d, max: Number(e.currentTarget.value) })}
class="field-input" />
</div>
{#if d.mode === 'step'}
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Paso</span>
<input type="number" value={d.step ?? 1}
oninput={e => editor.updateAttDomain(objId, secId, attId, { ...d, step: Number(e.currentTarget.value) })}
oninput={e => updateDomain({ ...d, step: Number(e.currentTarget.value) })}
class="field-input" />
</div>
{/if}
</div>
{:else if d.mode === 'list'}
<textarea
value={(d.list ?? []).join('\n')}
oninput={e => {
const list = e.currentTarget.value
.split('\n')
.map((s: string) => s.trim())
.filter((s: string) => s !== '' && !isNaN(Number(s)))
.map(Number)
updateDomain({ kind: att.value.domain.kind, mode: 'list', list } as any)
}}
placeholder="un valor por línea"
rows={4} class="field-input"
></textarea>
{:else if d.mode === 'fixed'}
<input type="number" value={d.fixed ?? 0}
oninput={e => editor.updateAttDomain(objId, secId, attId,
oninput={e => updateDomain(
{ kind: att.value.domain.kind, mode: 'fixed', fixed: Number(e.currentTarget.value) } as any)}
class="field-input" />
{/if}
</div>
{:else if att.value.domain.kind === 'option'}
{@const d = att.value.domain as any}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs text-gray-500">
Opciones asignadas
<span class="text-gray-700 ml-1">({attOptIds.size} de {allOpts.length})</span>
</label>
<div class="flex gap-2">
{#each [['list', 'Lista'], ['fixed', 'Fijo']] as [mode, modeLabel]}
<button
onclick={() => updateDomain(
mode === 'list'
? { kind: 'option', mode: 'list', list: d.list ?? [] }
: { kind: 'option', mode: 'fixed', fixed: d.fixed ?? '' }
)}
class="px-2.5 py-1 text-xs rounded border transition-colors
{d.mode === mode
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600'}"
>{modeLabel}</button>
{/each}
</div>
{#if allOpts.length === 0}
<p class="text-xs text-gray-700 italic">
No hay opciones globales — créalas en el panel Opciones
</p>
{:else}
<div class="flex flex-col gap-0.5 rounded border border-gray-800 bg-gray-900/50">
{#each allOpts as opt (opt.id)}
{@const checked = attOptIds.has(opt.id)}
<label class="flex items-center gap-3 px-3 py-2 hover:bg-gray-800
cursor-pointer transition-colors group">
<input type="checkbox" {checked}
onchange={() => toggleOpt(opt.id)}
class="accent-indigo-500 shrink-0" />
<span class="text-sm text-gray-300 flex-1 group-hover:text-gray-100">
{String(opt.value.value)}
</span>
{#if opt.label}
<span class="text-xs text-gray-600">
{typeof opt.label === 'string' ? opt.label : (opt.label as any)?.['es'] ?? ''}
</span>
{/if}
{#if opt.categoryId}
<span class="text-xs text-gray-700">
{editor.cat.optCategories.find(c => c.id === opt.categoryId)?.name ?? ''}
</span>
{/if}
</label>
{/each}
{#if d.mode === 'list'}
<div class="flex items-center justify-between">
<label class="text-xs text-gray-500">
Opciones asignadas
<span class="text-gray-700 ml-1">({attOptIds.size} de {allOpts.length})</span>
</label>
</div>
{#if allOpts.length === 0}
<p class="text-xs text-gray-700 italic">
No hay opciones globales — créalas en el panel Opciones
</p>
{:else}
<div class="flex flex-col gap-0.5 rounded border border-gray-800 bg-gray-900/50">
{#each allOpts as opt (opt.id)}
{@const checked = attOptIds.has(opt.id)}
{@const attOpt = checked ? (d.list ?? []).find((ao: any) => ao.optId === opt.id) : null}
<div class="px-3 py-2 hover:bg-gray-800 transition-colors">
<label class="flex items-center gap-3 cursor-pointer group">
<input type="checkbox" {checked}
onchange={() => toggleOpt(opt.id)}
class="accent-indigo-500 shrink-0" />
<span class="text-sm text-gray-300 flex-1 group-hover:text-gray-100">
{String(opt.value.value)}
</span>
{#if opt.categoryId}
<span class="text-xs text-gray-700">
{editor.cat.optCategories.find(c => c.id === opt.categoryId)?.name ?? ''}
</span>
{/if}
</label>
{#if checked}
<input type="text"
value={typeof attOpt?.label === 'string' ? attOpt.label : (attOpt?.label as any)?.['es'] ?? ''}
oninput={e => updateOptLabel(opt.id, e.currentTarget.value)}
placeholder="Etiqueta personalizada..."
class="mt-1.5 ml-7 field-input text-xs" />
{/if}
</div>
{/each}
</div>
{/if}
{:else if d.mode === 'fixed'}
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">Opción fija</label>
{#if allOpts.length === 0}
<p class="text-xs text-gray-700 italic">No hay opciones globales</p>
{:else}
<select
value={d.fixed ?? ''}
onchange={e => updateDomain({ kind: 'option', mode: 'fixed', fixed: e.currentTarget.value as any })}
class="field-input">
<option value="">— seleccionar —</option>
{#each allOpts as opt (opt.id)}
<option value={opt.id}>{String(opt.value.value)}</option>
{/each}
</select>
{/if}
</div>
{/if}
</div>
@ -264,7 +399,7 @@ function clearRules() {
{/each}
</div>
{:else if att.value.domain.kind === 'option'}
{:else if att.value.domain.kind === 'option' && (att.value.domain as any).mode === 'list'}
{@const d = att.value.domain as any}
<select
onchange={e => patch('default', e.currentTarget.value || null)}
@ -334,7 +469,7 @@ function clearRules() {
ruleSet={att.rules}
{container}
catOpts={editor.cat.opts}
{editor}
selfAttId={attId}
/>
{:else if !att.rules}
<p class="text-xs text-gray-700 italic">Sin reglas</p>

@ -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,66 +1,203 @@
<script lang="ts">
import type { Cat } from '@/vcen/lib'
import { getContext } from 'svelte'
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
import CatalogSidebar from './CatalogSidebar.svelte'
import CatMetaPanel from './CatMetaPanel.svelte'
import ObjsPanel from './ObjsPanel.svelte'
import OptsPanel from './OptsPanel.svelte'
import { buildDependencyGraph } from '@/vcen/lib/graph.ts'
// Helper: busca nombre legible de un nodo a partir de su key serializada
function resolveNodeLabel(cat: Cat, key: string): string {
const parts = key.split('/')
const last = parts[parts.length - 1]
// Try catalog atts
const catAtt = cat.atts?.find(a => a.id === last)
if (catAtt) return `Catálogo / ${catAtt.name || catAtt.id}`
// Try obj/sec/att
for (const obj of cat.objs) {
if (obj.id === last) return obj.name || obj.id
const objAtt = obj.atts.find(a => a.id === last)
if (objAtt) return `${obj.name || obj.id} / ${objAtt.name || objAtt.id}`
for (const sec of obj.secs) {
if (sec.id === last) return `${obj.name || obj.id} / ${sec.name || sec.id}`
const secAtt = sec.atts.find(a => a.id === last)
if (secAtt) return `${obj.name || obj.id} / ${sec.name || sec.id} / ${secAtt.name || secAtt.id}`
}
}
return key
}
import CatalogSidebar from './CatalogSidebar.svelte'
import CatMetaPanel from './CatMetaPanel.svelte'
import CatAttsPanel from './CatAttsPanel.svelte'
import OptsPanel from './OptsPanel.svelte'
import ObjInfoPanel from './ObjInfoPanel.svelte'
import ObjAttsPanel from './ObjAttsPanel.svelte'
import SecInfoPanel from './SecInfoPanel.svelte'
import SecAttsPanel from './SecAttsPanel.svelte'
let { onsave, oncancel }: {
onsave?: (cat : Cat) => void
onsave?: (cat: Cat) => void
oncancel?: () => void
} = $props()
const editor = getContext<CatalogEditorStore>('catalog-editor')
const panel = $derived(editor.activePanel)
const sel = $derived(editor.selection)
let showJson = $state(false)
let showGraph = $state(false)
const graphResult = $derived.by(() => {
try {
const g = buildDependencyGraph(editor.cat)
return { graph: g, error: null }
} catch (e: any) {
return { graph: null, error: e?.message ?? 'Error desconocido' }
}
})
const isValid = $derived(graphResult.graph !== null)
</script>
<div class="catalog-editor h-full">
<!-- Sidebar nav -->
<CatalogSidebar />
<!-- Panel principal -->
<div class="flex flex-col flex-1 overflow-hidden">
<!-- TopBar -->
<header class="flex items-center justify-between px-5 py-3 bg-gray-900
border-b border-gray-800 shrink-0">
<div class="flex items-center gap-3">
<span class="text-xs font-bold tracking-widest text-indigo-400 uppercase">CAT</span>
<span class="text-gray-700">|</span>
<span class="text-sm text-gray-300">{editor.cat.name || 'Sin nombre'}</span>
{#if editor.dirty}
<span class="text-xs text-amber-400">● Sin guardar</span>
{/if}
</div>
<div class="flex items-center gap-2">
{#if oncancel}
<button onclick={oncancel}
class="px-3 py-1.5 text-xs rounded border border-gray-700 text-gray-400
hover:text-gray-200 hover:border-gray-600 transition-colors">
Cancelar
</button>
{/if}
<div class="catalog-editor h-full flex flex-col">
<!-- Header -->
<header class="flex items-center gap-3 px-4 py-2.5 bg-gray-900 border-b border-gray-800 shrink-0">
<!-- Left -->
<div class="flex items-center gap-2 min-w-0">
<span class="text-xs font-bold tracking-widest text-indigo-400 uppercase shrink-0">VCEN</span>
<span class="text-gray-700 shrink-0">/</span>
<span class="text-sm text-gray-200 truncate">{editor.cat.name || 'Sin nombre'}</span>
{#if editor.dirty}
<span class="shrink-0 text-[10px] px-1.5 py-0.5 rounded bg-amber-900/40 text-amber-400 border border-amber-900/60">● modificado</span>
{/if}
</div>
<!-- Right -->
<div class="ml-auto flex items-center gap-1.5 shrink-0">
<button
onclick={() => showGraph = !showGraph}
class="px-2.5 py-1.5 text-xs rounded border transition-colors
{showGraph ? 'border-indigo-600 bg-indigo-600/10 text-indigo-400' : 'border-gray-700 text-gray-500 hover:text-gray-300 hover:border-gray-600'}"
>Grafo</button>
<button
onclick={() => showJson = !showJson}
class="px-2.5 py-1.5 text-xs rounded border transition-colors
{showJson ? 'border-indigo-600 bg-indigo-600/10 text-indigo-400' : 'border-gray-700 text-gray-500 hover:text-gray-300 hover:border-gray-600'}"
>JSON</button>
<!-- Valid indicator -->
<div
title={isValid ? 'Grafo válido' : 'Error en el grafo de dependencias'}
class="h-2 w-2 rounded-full mx-1 transition-colors
{isValid ? 'bg-green-500' : 'bg-red-500 animate-pulse'}"
></div>
{#if oncancel}
<button onclick={oncancel}
class="px-2.5 py-1.5 text-xs rounded border border-gray-700 text-gray-400
hover:text-gray-200 hover:border-gray-600 transition-colors">
Cancelar
</button>
{/if}
{#if editor.dirty}
<button
onclick={() => editor.discard()}
class="px-2.5 py-1.5 text-xs rounded border border-gray-700 text-gray-400
hover:text-red-400 hover:border-red-900 transition-colors">
Descartar
</button>
<button
onclick={() => { onsave?.(editor.getCat()); editor.markClean(); }}
disabled={!editor.dirty}
class="px-3 py-1.5 text-xs rounded bg-indigo-600 hover:bg-indigo-500
text-white font-medium transition-colors disabled:opacity-30
disabled:cursor-not-allowed">
text-white font-medium transition-colors">
Guardar
</button>
</div>
</header>
<!-- Panel activo -->
<div class="flex-1 overflow-hidden">
{#if panel === 'cat'} <CatMetaPanel />
{:else if panel === 'objs'} <ObjsPanel />
{:else if panel === 'opts'} <OptsPanel />
{/if}
</div>
</header>
<!-- Body -->
<div class="flex flex-1 min-h-0">
<!-- Left sidebar tree -->
<div class="w-64 shrink-0 border-r border-gray-800 bg-gray-950 overflow-y-auto">
<CatalogSidebar />
</div>
<!-- Main content -->
<div class="flex-1 min-w-0 overflow-hidden relative">
{#if sel.kind === 'cat-info'} <CatMetaPanel />
{:else if sel.kind === 'cat-atts'} <CatAttsPanel />
{:else if sel.kind === 'opts'} <OptsPanel />
{:else if sel.kind === 'obj-info'} <ObjInfoPanel />
{:else if sel.kind === 'obj-atts'} <ObjAttsPanel />
{:else if sel.kind === 'sec-info'} <SecInfoPanel />
{:else if sel.kind === 'sec-atts'} <SecAttsPanel />
{/if}
<!-- JSON overlay -->
{#if showJson}
<div class="absolute inset-0 bg-gray-950/95 z-30 overflow-auto p-4">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">JSON del catálogo</span>
<button onclick={() => showJson = false} class="text-gray-600 hover:text-gray-300 text-xs">✕ Cerrar</button>
</div>
<pre class="text-xs text-green-400 font-mono whitespace-pre-wrap">{JSON.stringify(editor.cat, null, 2)}</pre>
</div>
{/if}
<!-- Graph overlay -->
{#if showGraph}
<div class="absolute inset-0 bg-gray-950/97 z-30 overflow-auto p-4">
<div class="flex items-center justify-between mb-4">
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Grafo de dependencias</span>
<button onclick={() => showGraph = false} class="text-gray-600 hover:text-gray-300 text-xs">✕ Cerrar</button>
</div>
{#if graphResult.error}
<div class="rounded border border-red-900/60 bg-red-900/20 p-4 text-xs text-red-400 font-mono whitespace-pre-wrap">
{graphResult.error}
</div>
{:else if graphResult.graph}
{@const g = graphResult.graph}
{@const deps = [...g.attDeps.entries()].filter(([, set]) => set.size > 0)}
{#if deps.length === 0}
<p class="text-xs text-gray-700 italic">Sin dependencias entre atributos.</p>
{:else}
<div class="flex flex-col gap-2">
{#each deps as [key, depSet]}
<div class="rounded border border-gray-800 bg-gray-900/60 px-3 py-2">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs font-medium text-indigo-300">
{resolveNodeLabel(editor.cat, key)}
</span>
<span class="text-xs text-gray-600">depende de</span>
<div class="flex gap-1.5 flex-wrap">
{#each [...depSet] as dep}
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-amber-400 font-mono">
{resolveNodeLabel(editor.cat, dep)}
</span>
{/each}
</div>
</div>
</div>
{/each}
</div>
<div class="mt-4 pt-4 border-t border-gray-800">
<p class="text-xs text-gray-600 mb-2 uppercase tracking-wider">Resumen</p>
<p class="text-xs text-gray-500">
{g.attDeps.size} nodos ·
{[...g.attDeps.values()].reduce((s, set) => s + set.size, 0)} dependencias ·
{g.ruleMeta.size} reglas
</p>
</div>
{/if}
{/if}
</div>
{/if}
</div>
</div>
</div>

@ -1,33 +1,350 @@
<script lang="ts">
import { getContext } from 'svelte'
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>
{#each nav as item}
<!-- 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={() => 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'}"
>
<span class="text-base leading-none">{item.icon}</span>
<span>{item.label}</span>
</button>
{/each}
</nav>
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>
{#if isObjOpen}
<!-- obj subitems -->
<button
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}"
>
<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}
{/if}
{/each}
{/if}
</div>

@ -34,7 +34,7 @@ const isEmpty = $derived(
// Scope para los selects de atributo
const scopeAttrs = $derived(
store.scope.map(e => ({ id: e.path, label: e.path.replace('/', ' / ') }))
store.scope.map(e => ({ id: e.path, label: e.label }))
)
function getLeftPath(node: ConditionNode): string {
@ -233,7 +233,7 @@ const OPS_LABELS: Record<string, string> = {
hover:border-indigo-700 transition-colors"
>+ Añadir cláusula</button>
{#if store.tautology.kind !== 'not-tautology'}
{#if store.tautology.kind !== 'unknown'}
<p class="text-xs text-amber-700 italic">
⚠ {store.tautology.kind === 'always-true' ? 'Siempre verdadera' : 'Siempre falsa'}
</p>

@ -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}

@ -1,18 +1,7 @@
<script lang="ts">
/**
* RULE EDITOR — editor de una regla individual
* Sigue el patrón exacto de RuleEditor.svelte de referencia:
* - Header: severity select + action type select + target select + delete
* - Chips de valores (cuando aplica)
* - ConditionBuilder
* - Mensaje (label/descripción)
*/
import { getContext } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type {
RuleSet, Rule,
RuleAction, Obj, Sec, Opt, AttState
} from '@/vcen/lib'
import type { RuleSet, Rule, Obj, Sec, AttState } from '@/vcen/lib'
import { createConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.ts'
import ConditionBuilder from './ConditionBuilder.svelte'
@ -23,21 +12,16 @@ let {
ruleSet,
container,
catOpts,
selfAttId,
}: {
rule: Rule
ruleSet: RuleSet
container: Obj | Sec
catOpts: Opt[]
rule: Rule
ruleSet: RuleSet
container: Obj | Sec
catOpts: any[]
selfAttId?: string
} = $props()
// Condition builder store — estable, no se recrea en cada render
let cbStore = $state(createConditionBuilderStore(container, rule.condition, catOpts))
$effect(() => {
// Si cambia la regla externamente, sync sin recrear
if (cbStore && rule.condition !== undefined) {
// store gestiona internamente — solo emit al cambio local
}
})
const cbStore = createConditionBuilderStore(container, rule.condition, catOpts, editor.cat.atts ?? [])
function syncCondition() {
if (cbStore.isValid) {
@ -45,86 +29,85 @@ function syncCondition() {
}
}
// Todos los atributos del container como targets
const allAttrs = $derived.by(() => {
// Atributos del contenedor (para el selector de target en reglas de atributo)
const containerAttrs = $derived.by(() => {
const attrs: Array<{ id: string; label: string }> = []
const objs = editor.cat.objs
for (const obj of objs) {
for (const att of obj.atts) {
attrs.push({ id: att.id, label: `${obj.name} / ${att.name || att.id}` })
if ('secs' in container) {
for (const att of container.atts) {
attrs.push({ id: att.id, label: att.name || att.id })
}
for (const sec of obj.secs) {
for (const sec of (container as Obj).secs) {
for (const att of sec.atts) {
attrs.push({ id: att.id, label: `${obj.name} / ${sec.name} / ${att.name || att.id}` })
attrs.push({ id: att.id, label: `${sec.name || sec.id} / ${att.name || att.id}` })
}
}
} else {
for (const att of container.atts) {
attrs.push({ id: att.id, label: att.name || att.id })
}
}
return attrs
return selfAttId ? attrs.filter(a => a.id !== selfAttId) : attrs
})
// Action types
const ATT_STATES: { state: AttState; label: string }[] = [
{ state: 'available', label: 'Disponible' },
{ state: 'required', label: 'Requerido' },
{ state: 'hidden', label: 'Oculto' },
{ state: 'forbidden', label: 'Prohibido' },
]
// La acción de la regla — siempre la primera, tipo 'attribute'
const action = $derived.by(() => {
const a = rule.actions.find(a => a.type === 'attribute')
return a as { type: 'attribute'; target: string[]; ifTrue: AttState } | undefined
})
const ACTION_KINDS = [
{ kind: 'attribute', label: 'Estado atributo' },
{ kind: 'validation', label: 'Validación' },
{ kind: 'domain', label: 'Dominio' },
{ kind: 'value', label: 'Valor' },
] as const
const ifTrue = $derived(action?.ifTrue ?? 'hidden')
const target = $derived((action?.target ?? [])[0] ?? '')
function onchange(patch: Partial<Rule>) {
editor.updateRule(ruleSet, rule.id, patch)
}
function patchAction(idx: number, changes: Record<string, unknown>) {
const action = { ...rule.actions[idx], ...changes }
editor.updateAction(ruleSet, rule.id, idx, action as any)
function setIfTrue(state: AttState) {
const act = { type: 'attribute' as const, target: action?.target ?? [], ifTrue: state, ifFalse: 'available' as AttState }
if (action) {
const idx = rule.actions.indexOf(action as any)
editor.updateAction(ruleSet, rule.id, idx, act)
} else {
editor.addAction(ruleSet, rule.id, act)
}
}
function addAction(kind: string) {
const defaults: Record<string, RuleAction> = {
attribute: { type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' },
validation: { type: 'validation', level: 'error', message: '' },
domain: { type: 'domain', target: [], state: 'allowed' },
value: { type: 'value', target: [], value: { domain: { kind: 'string', mode: 'input' }, value: '' } },
function setTarget(targetId: string) {
const act = { type: 'attribute' as const, target: targetId ? [targetId] as any : [], ifTrue, ifFalse: 'available' as AttState }
if (action) {
const idx = rule.actions.indexOf(action as any)
editor.updateAction(ruleSet, rule.id, idx, act)
} else {
editor.addAction(ruleSet, rule.id, act)
}
editor.addAction(ruleSet, rule.id, defaults[kind] ?? defaults.attribute)
}
const ATT_STATES: { state: AttState; label: string; color: string }[] = [
{ state: 'available', label: 'Disponible', color: 'text-green-400' },
{ state: 'required', label: 'Requerido', color: 'text-blue-400' },
{ state: 'hidden', label: 'Oculto', color: 'text-gray-400' },
{ state: 'forbidden', label: 'Prohibido', color: 'text-red-400' },
]
</script>
<div class="rounded border border-gray-800 bg-gray-900/60 flex flex-col overflow-hidden">
<!-- Header: prioridad + actions count + delete -->
<!-- Header: prioridad + label + delete -->
<div class="flex items-center gap-2 px-3 py-2 bg-gray-900 border-b border-gray-800">
<span class="text-xs text-gray-600 shrink-0">P</span>
<input
type="number"
value={rule.priority ?? 0}
oninput={e => onchange({ priority: Number(e.currentTarget.value) })}
oninput={e => editor.updateRule(ruleSet, rule.id, { priority: Number(e.currentTarget.value) })}
class="w-12 rounded border border-gray-700 bg-gray-800 px-1.5 py-0.5
text-xs text-gray-300 focus:outline-none focus:border-indigo-500 shrink-0"
/>
<input
type="text"
value={(rule as any).label ?? ''}
oninput={e => onchange({ label: e.currentTarget.value } as any)}
oninput={e => editor.updateRule(ruleSet, rule.id, { label: e.currentTarget.value } as any)}
placeholder="Descripción de la regla..."
class="flex-1 bg-transparent border-transparent text-xs text-gray-400
placeholder-gray-700 rounded px-1.5 py-0.5
hover:border-gray-700 focus:border-gray-600 focus:bg-gray-800
border focus:outline-none transition-colors"
/>
<span class="text-xs text-gray-700 shrink-0">
{rule.actions.length} acc
</span>
<button
onclick={() => editor.removeRule(ruleSet, rule.id)}
class="text-gray-600 hover:text-red-400 transition-colors text-xs shrink-0 px-1"
@ -135,156 +118,41 @@ function addAction(kind: string) {
<!-- Condición -->
<div class="px-3 py-2.5 border-b border-gray-800">
<ConditionBuilder
store={cbStore}
onchange={syncCondition}
/>
<ConditionBuilder store={cbStore} onchange={syncCondition} />
</div>
<!-- Acciones -->
<!-- Resultado -->
<div class="px-3 py-2.5 flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-600 uppercase tracking-wider">Acciones</span>
<select
onchange={e => { const v = e.currentTarget.value; if (v) { addAction(v); e.currentTarget.value = ''; } }}
class="text-xs rounded border border-gray-700 bg-gray-800 text-gray-400
px-1.5 py-0.5 focus:outline-none focus:border-indigo-500"
>
<option value="">+ añadir...</option>
{#each ACTION_KINDS as { kind, label }}
<option value={kind}>{label}</option>
{/each}
</select>
<span class="text-xs text-gray-600 uppercase tracking-wider">Cuando se cumple</span>
<!-- Estado -->
<div class="flex gap-1.5 flex-wrap">
{#each ATT_STATES as s}
<button
onclick={() => setIfTrue(s.state)}
class="px-2.5 py-1 text-xs rounded border transition-colors
{ifTrue === s.state
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: `border-gray-700 text-gray-500 hover:border-gray-600 ${s.color}`}"
>{s.label}</button>
{/each}
</div>
{#if rule.actions.length === 0}
<p class="text-xs text-gray-700 italic">Sin acciones</p>
{/if}
{#each rule.actions as action, idx (idx)}
<div class="rounded border border-gray-700 bg-gray-900 flex flex-col gap-2 p-2.5">
<!-- Fila header de la acción -->
<div class="flex items-center gap-2">
<span class="text-xs px-1.5 py-0.5 rounded bg-indigo-900/40 text-indigo-400 shrink-0 font-medium">
{ACTION_KINDS.find(k => k.kind === action.type)?.label ?? action.type}
</span>
<input
type="text"
value={(action as any).label ?? ''}
oninput={e => patchAction(idx, { label: e.currentTarget.value })}
placeholder="Descripción..."
class="flex-1 bg-transparent border-transparent text-xs text-gray-500
placeholder-gray-700 rounded px-1 py-0.5 hover:border-gray-700
focus:border-gray-600 focus:bg-gray-800 border
focus:outline-none transition-colors"
/>
<button
onclick={() => editor.removeAction(ruleSet, rule.id, idx)}
class="text-gray-600 hover:text-red-400 transition-colors text-xs shrink-0"
>✕</button>
</div>
<!-- Campos según tipo -->
{#if action.type === 'attribute'}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Si cumple</span>
<select value={action.ifTrue}
onchange={e => patchAction(idx, { ifTrue: e.currentTarget.value })}
class="field-input">
{#each ATT_STATES as { state, label }}
<option value={state}>{label}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Si no cumple</span>
<select value={action.ifFalse ?? 'available'}
onchange={e => patchAction(idx, { ifFalse: e.currentTarget.value })}
class="field-input">
{#each ATT_STATES as { state, label }}
<option value={state}>{label}</option>
{/each}
</select>
</div>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Target <span class="text-gray-700">(vacío = este atributo)</span></span>
<select
value={(action.target ?? [])[0] ?? ''}
onchange={e => patchAction(idx, { target: e.currentTarget.value ? [e.currentTarget.value] : [] })}
class="field-input">
<option value="">— este atributo —</option>
{#each allAttrs as a}
<option value={a.id}>{a.label}</option>
{/each}
</select>
</div>
{:else if action.type === 'validation'}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Nivel</span>
<select value={action.level}
onchange={e => patchAction(idx, { level: e.currentTarget.value })}
class="field-input {action.level === 'error' ? 'text-red-400' : 'text-amber-400'}">
<option value="error">Error</option>
<option value="warning">Aviso</option>
</select>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Mensaje</span>
<input type="text" value={action.message ?? ''}
oninput={e => patchAction(idx, { message: e.currentTarget.value })}
placeholder="Campo obligatorio"
class="field-input" />
</div>
</div>
{:else if action.type === 'domain'}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Estado</span>
<select value={action.state}
onchange={e => patchAction(idx, { state: e.currentTarget.value })}
class="field-input">
<option value="allowed">Permitido</option>
<option value="forbidden">Prohibido</option>
</select>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Target</span>
<select
value={(action.target ?? [])[0] ?? ''}
onchange={e => patchAction(idx, { target: e.currentTarget.value ? [e.currentTarget.value] : [] })}
class="field-input">
<option value="">— atributo —</option>
{#each allAttrs as a}
<option value={a.id}>{a.label}</option>
{/each}
</select>
</div>
</div>
{:else if action.type === 'value'}
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Valor (JSON)</span>
<input type="text"
value={JSON.stringify((action.value as any)?.value ?? '')}
oninput={e => {
try {
const parsed = JSON.parse(e.currentTarget.value)
patchAction(idx, { value: { ...action.value, value: parsed } })
} catch { /* esperar JSON válido */ }
}}
placeholder='"texto" o 42 o true'
class="field-input font-mono text-xs" />
</div>
{/if}
<!-- Target (solo para reglas de atributo: selfAttId !== undefined) -->
{#if selfAttId !== undefined && containerAttrs.length > 0}
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Aplicar a</span>
<select
value={target}
onchange={e => setTarget(e.currentTarget.value)}
class="field-input text-xs">
<option value="">— este atributo —</option>
{#each containerAttrs as a}
<option value={a.id}>{a.label}</option>
{/each}
</select>
</div>
{/each}
{/if}
</div>
</div>

@ -14,10 +14,12 @@ let {
ruleSet,
container,
catOpts,
selfAttId,
}: {
ruleSet: RuleSet
container: Obj | Sec
catOpts: typeof editor.cat.opts
ruleSet: RuleSet
container: Obj | Sec
catOpts: typeof editor.cat.opts
selfAttId?: string
} = $props()
function addRule() {
@ -36,7 +38,7 @@ function addRule() {
{ruleSet}
{container}
{catOpts}
{editor}
{selfAttId}
/>
{/each}
{/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}

@ -9,14 +9,19 @@ import type { Cat, Obj, Sec, Att, AttDomain, AttValue,
// SELECTION — nodo activo en el árbol
// ─────────────────────────────────────────────────────────────────────────────
export type SelectionKind = 'cat' | 'obj' | 'sec' | 'att' | 'opts';
export type SelectionKind =
| 'cat-info' | 'cat-atts' | 'opts'
| 'obj-info' | 'obj-atts'
| 'sec-info' | 'sec-atts';
export type Selection =
| { kind: 'cat' }
| { kind: 'opts' } // panel Opts/OptCategories
| { kind: 'obj'; objId: ObjID }
| { kind: 'sec'; objId: ObjID; secId: SecID }
| { kind: 'att'; objId: ObjID; secId?: SecID; attId: AttID };
| { kind: 'cat-info' }
| { kind: 'cat-atts'; attId?: AttID }
| { kind: 'opts' }
| { kind: 'obj-info'; objId: ObjID }
| { kind: 'obj-atts'; objId: ObjID; attId?: AttID }
| { kind: 'sec-info'; objId: ObjID; secId: SecID }
| { kind: 'sec-atts'; objId: ObjID; secId: SecID; attId?: AttID };
// ─────────────────────────────────────────────────────────────────────────────
// ID GENERATORS
@ -35,16 +40,19 @@ function defaultAttValue(): AttValue {
return { domain: { kind: 'string', mode: 'input' }, value: '' };
}
function defaultAtt(name = 'Nuevo atributo'): Att {
return { id: nextId<AttID>('at'), name, value: defaultAttValue() };
function defaultAtt(name = 'Nuevo atributo', slug?: string): Att {
const id = slug ? `at:${slug}` as AttID : nextId<AttID>('at');
return { id, name, value: defaultAttValue() };
}
function defaultSec(name = 'Nueva sección'): Sec {
return { id: nextId<SecID>('sc'), name, atts: [] };
function defaultSec(name = 'Nueva sección', slug?: string): Sec {
const id = slug ? `sc:${slug}` as SecID : nextId<SecID>('sc');
return { id, name, atts: [] };
}
function defaultObj(name = 'Nuevo objeto'): Obj {
return { id: nextId<ObjID>('ob'), name, atts: [], secs: [] };
function defaultObj(name = 'Nuevo objeto', slug?: string): Obj {
const id = slug ? `ob:${slug}` as ObjID : nextId<ObjID>('ob');
return { id, name, atts: [], secs: [] };
}
function defaultOpt(value = ''): Opt {
@ -63,7 +71,7 @@ function defaultRule(): Rule {
id: nextId<RuleID>('rl'),
priority: 10,
condition: true,
actions: [],
actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
};
}
@ -90,10 +98,9 @@ function defaultRule(): Rule {
* ```
*/
export function createCatalogEditorStore(initial: Cat) {
let cat = $state<Cat>(structuredClone(initial));
let selection = $state<Selection>({ kind: 'cat' });
let cat = $state<Cat>(JSON.parse(JSON.stringify(initial)));
let selection = $state<Selection>({ kind: 'cat-info' });
let dirty = $state(false);
let panel = $state<'cat' | 'objs' | 'opts'>('objs');
// ── Helpers internos ────────────────────────────────────────────────────
@ -133,10 +140,10 @@ export function createCatalogEditorStore(initial: Cat) {
// ── Obj ─────────────────────────────────────────────────────────────────
function addObj(name?: string): ObjID {
const obj = defaultObj(name);
function addObj(name?: string, slug?: string): ObjID {
const obj = defaultObj(name, slug);
mutate(() => { cat.objs.push(obj); });
select({ kind: 'obj', objId: obj.id });
select({ kind: 'obj-info', objId: obj.id });
return obj.id;
}
@ -152,8 +159,13 @@ export function createCatalogEditorStore(initial: Cat) {
cat.objs = cat.objs.filter(o => o.id !== objId);
});
// Limpiar selección si apuntaba al obj eliminado
if (selection.kind === 'obj' && selection.objId === objId) select({ kind: 'cat' });
if ((selection.kind === 'sec' || selection.kind === 'att') && selection.objId === objId) select({ kind: 'cat' });
const s = selection;
if (
(s.kind === 'obj-info' || s.kind === 'obj-atts') && s.objId === objId ||
(s.kind === 'sec-info' || s.kind === 'sec-atts') && s.objId === objId
) {
select({ kind: 'cat-info' });
}
}
function moveObj(objId: ObjID, direction: 'up' | 'down') {
@ -168,13 +180,13 @@ export function createCatalogEditorStore(initial: Cat) {
// ── Sec ─────────────────────────────────────────────────────────────────
function addSec(objId: ObjID, name?: string): SecID {
const sec = defaultSec(name);
function addSec(objId: ObjID, name?: string, slug?: string): SecID {
const sec = defaultSec(name, slug);
mutate(() => {
const obj = findObj(objId);
if (obj) obj.secs.push(sec);
});
select({ kind: 'sec', objId, secId: sec.id });
select({ kind: 'sec-info', objId, secId: sec.id });
return sec.id;
}
@ -190,8 +202,12 @@ export function createCatalogEditorStore(initial: Cat) {
const obj = findObj(objId);
if (obj) obj.secs = obj.secs.filter(s => s.id !== secId);
});
if (selection.kind === 'sec' && selection.secId === secId) select({ kind: 'obj', objId });
if (selection.kind === 'att' && selection.secId === secId) select({ kind: 'obj', objId });
const s = selection;
if (
(s.kind === 'sec-info' || s.kind === 'sec-atts') && s.secId === secId
) {
select({ kind: 'obj-atts', objId });
}
}
function moveSec(objId: ObjID, secId: SecID, direction: 'up' | 'down') {
@ -215,8 +231,8 @@ export function createCatalogEditorStore(initial: Cat) {
// ── Att (en Obj o Sec) ──────────────────────────────────────────────────
function addAtt(objId: ObjID, secId: SecID | undefined, name?: string): AttID {
const att = defaultAtt(name);
function addAtt(objId: ObjID, secId: SecID | undefined, name?: string, slug?: string): AttID {
const att = defaultAtt(name, slug);
mutate(() => {
if (secId) {
const sec = findSec(objId, secId);
@ -226,14 +242,18 @@ export function createCatalogEditorStore(initial: Cat) {
if (obj) obj.atts.push(att);
}
});
select({ kind: 'att', objId, secId, attId: att.id });
if (secId) {
select({ kind: 'sec-atts', objId, secId, attId: att.id });
} else {
select({ kind: 'obj-atts', objId, attId: att.id });
}
return att.id;
}
function addCatAtt(name?: string): AttID {
const att = defaultAtt(name);
function addCatAtt(name?: string, slug?: string): AttID {
const att = defaultAtt(name, slug);
mutate(() => { cat.atts.push(att); });
select({ kind: 'att', objId: '' as ObjID, secId: undefined, attId: att.id });
select({ kind: 'cat-atts', attId: att.id });
return att.id;
}
@ -244,6 +264,13 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
function renameCatAtt(attId: AttID, name: string) {
mutate(() => {
const att = findCatAtt(attId);
if (att) att.name = name;
});
}
function removeAtt(objId: ObjID, secId: SecID | undefined, attId: AttID) {
mutate(() => {
if (secId) {
@ -254,8 +281,21 @@ export function createCatalogEditorStore(initial: Cat) {
if (obj) obj.atts = obj.atts.filter(a => a.id !== attId);
}
});
if (selection.kind === 'att' && selection.attId === attId) {
select(secId ? { kind: 'sec', objId, secId } : { kind: 'obj', objId });
const s = selection;
if (s.kind === 'obj-atts' && s.attId === attId) {
select({ kind: 'obj-atts', objId });
} else if (s.kind === 'sec-atts' && s.attId === attId) {
select({ kind: 'sec-atts', objId, secId: secId! });
}
}
function removeCatAtt(attId: AttID) {
mutate(() => {
cat.atts = cat.atts.filter(a => a.id !== attId);
});
const s = selection;
if (s.kind === 'cat-atts' && s.attId === attId) {
select({ kind: 'cat-atts' });
}
}
@ -280,6 +320,14 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
function updateCatAttDomain(attId: AttID, domain: AttDomain) {
mutate(() => {
const att = findCatAtt(attId);
if (!att) return;
att.value = { domain, value: defaultValueForDomain(domain) } as AttValue;
});
}
function updateAttDefaultValue(objId: ObjID, secId: SecID | undefined, attId: AttID, value: unknown) {
mutate(() => {
const att = findAtt(objId, secId, attId);
@ -287,6 +335,13 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
function updateCatAttDefaultValue(attId: AttID, value: unknown) {
mutate(() => {
const att = findCatAtt(attId);
if (att) att.value = { ...att.value, value } as typeof att.value;
});
}
function setAttRuleSet(objId: ObjID, secId: SecID | undefined, attId: AttID, ruleSet: RuleSet | undefined) {
mutate(() => {
const att = findAtt(objId, secId, attId);
@ -294,6 +349,13 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
function setCatAttRuleSet(attId: AttID, ruleSet: RuleSet | undefined) {
mutate(() => {
const att = findCatAtt(attId);
if (att) att.rules = ruleSet;
});
}
function setObjRuleSet(objId: ObjID, ruleSet: RuleSet | undefined) {
mutate(() => {
const obj = findObj(objId);
@ -369,6 +431,41 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
function addCatAttOpt(attId: AttID, optId: OptID) {
mutate(() => {
const att = findCatAtt(attId)
if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
const list = (att.value.domain as any).list as AttOpt[]
if (!list.find((ao: AttOpt) => ao.optId === optId)) list.push({ optId })
})
}
function removeCatAttOpt(attId: AttID, optId: OptID) {
mutate(() => {
const att = findCatAtt(attId)
if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
;(att.value.domain as any).list = ((att.value.domain as any).list as AttOpt[]).filter((ao: AttOpt) => ao.optId !== optId)
})
}
function updateAttOptLabel(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID, label: string) {
mutate(() => {
const att = findAtt(objId, secId, attId)
if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
const attOpt = ((att.value.domain as any).list as AttOpt[]).find((ao: AttOpt) => ao.optId === optId)
if (attOpt) attOpt.label = label || undefined
})
}
function updateCatAttOptLabel(attId: AttID, optId: OptID, label: string) {
mutate(() => {
const att = findCatAtt(attId)
if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
const attOpt = ((att.value.domain as any).list as AttOpt[]).find((ao: AttOpt) => ao.optId === optId)
if (attOpt) attOpt.label = label || undefined
})
}
// ── OptCategories ───────────────────────────────────────────────────────
function addOptCategory(name?: string): OptCatID {
@ -402,12 +499,13 @@ export function createCatalogEditorStore(initial: Cat) {
return rule.id;
}
function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial<Pick<Rule, 'condition' | 'priority'>>) {
function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial<Rule>) {
mutate(() => {
const rule = ruleSet.rules.find(r => r.id === ruleId);
if (!rule) return;
if (patch.condition !== undefined) rule.condition = patch.condition;
if (patch.priority !== undefined) rule.priority = patch.priority;
if ((patch as any).label !== undefined) (rule as any).label = (patch as any).label;
});
}
@ -438,6 +536,25 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
// ── Slug validators ─────────────────────────────────────────────────────
function isObjSlugTaken(slug: string): boolean {
return cat.objs.some(o => o.id === `ob:${slug}`);
}
function isSecSlugTaken(slug: string): boolean {
return cat.objs.some(o => o.secs.some(s => s.id === `sc:${slug}`));
}
function isAttSlugTaken(slug: string): boolean {
const id = `at:${slug}`;
if (cat.atts.some(a => a.id === id)) return true;
return cat.objs.some(o =>
o.atts.some(a => a.id === id) ||
o.secs.some(s => s.atts.some(a => a.id === id))
);
}
// ── Persistencia ────────────────────────────────────────────────────────
/** Devuelve el Cat en estado actual. Útil para guardar. */
@ -452,9 +569,9 @@ export function createCatalogEditorStore(initial: Cat) {
/** Descarta todos los cambios y vuelve al Cat inicial. */
function discard() {
cat = structuredClone(initial);
cat = JSON.parse(JSON.stringify(initial));
dirty = false;
selection = { kind: 'cat' };
selection = { kind: 'cat-info' };
}
// ── Opt activa (para OptsPanel) ──────────────────────────────────────────
@ -486,12 +603,18 @@ export function createCatalogEditorStore(initial: Cat) {
addSec, renameSec, removeSec, moveSec,
setSecRuleSet,
// Att
// Att (obj/sec level)
addAtt, addCatAtt, renameAtt, removeAtt, moveAtt,
updateAttDomain, updateAttDefaultValue, setAttRuleSet,
// Att (cat level)
renameCatAtt, removeCatAtt,
updateCatAttDomain, updateCatAttDefaultValue, setCatAttRuleSet,
// Opts globales
addOpt, updateOpt, removeOpt, addAttOpt, removeAttOpt,
addCatAttOpt, removeCatAttOpt,
updateAttOptLabel, updateCatAttOptLabel,
// OptCategories
addOptCategory, renameOptCategory, removeOptCategory,
@ -500,13 +623,12 @@ export function createCatalogEditorStore(initial: Cat) {
addRule, updateRule, removeRule,
addAction, updateAction, removeAction,
// Slug validators
isObjSlugTaken, isSecSlugTaken, isAttSlugTaken,
// Persistencia
getCat, markClean, discard,
// Panel
get activePanel() { return panel; },
setPanel(p: 'cat' | 'objs' | 'opts') { panel = p; },
// Acceso a finders para los paneles
findObj, findSec, findAtt, findCatAtt,
};

@ -53,6 +53,7 @@ export interface ResolvedOpt {
export interface ScopeEntry {
path: string; // relativo al contenedor: "at_color", "sc_motor/at_fuel"
label: string; // nombre legible: "País", "Catálogo / País", "Motor / Combustible"
att: Att;
domain: AttDomain;
resolvedOpts?: ResolvedOpt[]; // solo presente si domain.kind === 'option' && mode === 'list'
@ -60,7 +61,8 @@ export interface ScopeEntry {
/**
* Construye el scope de variables disponibles para una regla.
* El scope incluye todos los atts del contenedor y sus secciones hijas.
* El scope incluye todos los atts del contenedor y sus secciones hijas,
* más los atts de nivel catálogo (prefijados con "cat/").
*
* Para atts de dominio `option/list`, resuelve las opciones disponibles
* cruzando las `AttOpt` del dominio con los `Opt` globales del catálogo,
@ -68,14 +70,31 @@ export interface ScopeEntry {
*
* @param container - Obj o Sec que contiene la regla
* @param catOpts - Opts globales del catálogo (Cat.opts)
* @param catAtts - Atts de nivel catálogo (Cat.atts)
*/
export function buildScope(container: Obj | Sec, catOpts: Opt[] = []): ScopeEntry[] {
function nameStr(name: unknown, fallback: string): string {
return typeof name === 'string' ? (name || fallback) : fallback;
}
export function buildScope(container: Obj | Sec, catOpts: Opt[] = [], catAtts: Att[] = []): ScopeEntry[] {
const entries: ScopeEntry[] = [];
// Atts de nivel catálogo — visibles desde cualquier regla
for (const att of catAtts) {
entries.push({
path: `cat/${att.id}`,
label: `Catálogo / ${nameStr(att.name, att.id)}`,
att,
domain: att.value.domain,
resolvedOpts: resolveOpts(att.value.domain, catOpts),
});
}
const atts = 'atts' in container ? container.atts : [];
for (const att of atts) {
entries.push({
path: att.id,
label: nameStr(att.name, att.id),
att,
domain: att.value.domain,
resolvedOpts: resolveOpts(att.value.domain, catOpts),
@ -84,9 +103,11 @@ export function buildScope(container: Obj | Sec, catOpts: Opt[] = []): ScopeEntr
if ('secs' in container) {
for (const sec of container.secs) {
const secLabel = nameStr(sec.name, sec.id);
for (const att of sec.atts) {
entries.push({
path: `${sec.id}/${att.id}`,
label: `${secLabel} / ${nameStr(att.name, att.id)}`,
att,
domain: att.value.domain,
resolvedOpts: resolveOpts(att.value.domain, catOpts),
@ -271,8 +292,15 @@ function newId(): string { return `node_${++_idCounter}`; }
* Útil para cargar reglas existentes en el editor.
*/
export function parseCondition(value: JsonLogicValue): ConditionNode {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
// Literal booleano como condición raíz — envolver en compare
// true/false/null como condición raíz → nodo lógico vacío (true = siempre activa)
if (value === true || value === null) {
return makeLogicNode('and', []);
}
if (value === false) {
return makeLogicNode('or', []);
}
if (typeof value !== 'object' || Array.isArray(value)) {
// Otro literal primitivo — envolver en compare
return makeCompareNode('==', { kind: 'literal', value }, { kind: 'literal', value: true });
}
@ -425,9 +453,6 @@ export function validateCondition(
function walk(n: ConditionNode) {
if (n.kind === 'logic') {
if (n.children.length === 0) {
errors.push({ nodeId: n.id, message: `Operador "${n.op}" sin condiciones hijas` });
}
n.children.forEach(walk);
return;
}

@ -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
}

@ -63,7 +63,7 @@ function defaultRule(): Rule {
id: nextId<RuleID>('rl'),
priority: 10,
condition: true,
actions: [],
actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
};
}
@ -401,12 +401,13 @@ export function createCatalogEditorStore(initial: Cat) {
return rule.id;
}
function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial<Pick<Rule, 'condition' | 'priority'>>) {
function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial<Rule>) {
mutate(() => {
const rule = ruleSet.rules.find(r => r.id === ruleId);
if (!rule) return;
if (patch.condition !== undefined) rule.condition = patch.condition;
if (patch.priority !== undefined) rule.priority = patch.priority;
if ((patch as any).label !== undefined) (rule as any).label = (patch as any).label;
});
}

@ -1,5 +1,5 @@
import type { JsonLogicValue } from '@/jslg/lib';
import type {Obj, Opt, Sec} from '@/vcen/lib/types';
import type {Att, Obj, Opt, Sec} from '@/vcen/lib/types';
import type {
ScopeEntry,
ConditionNode,
@ -54,6 +54,7 @@ export function createConditionBuilderStore(
container: Obj | Sec,
initial?: JsonLogicValue,
catOpts: Opt[] = [],
catAtts: Att[] = [],
) {
// ── Estado reactivo ───────────────────────────────────────────────────────
@ -63,7 +64,7 @@ export function createConditionBuilderStore(
: makeLogicNode('and', [])
);
const scope = $derived<ScopeEntry[]>(buildScope(container, catOpts));
const scope = $derived<ScopeEntry[]>(buildScope(container, catOpts, catAtts));
const errors = $derived<ConditionError[]>(
validateCondition(root, scope)

@ -299,6 +299,7 @@ describe('validateCondition', () => {
function makeScope(...atts: [string, string][]): ScopeEntry[] {
return atts.map(([path, kind]) => ({
path,
label: path.split('/').pop()!,
att: makeAtt(path.split('/').pop()!),
domain: { kind } as any,
}));

Loading…
Cancel
Save

Powered by TurnKey Linux.