dev 7 months ago
parent 41f07e6605
commit 4f2be98381

@ -1,9 +1,7 @@
<script lang="ts">
import {createCatalogEditorStore} from "@/vcen/editor/stores/catalog-editor.svelte.ts";
import {type Cat, toCatID} from "@/vcen/lib";
import CatalogTree from "@/vcen/editor/comps/CatalogTree.svelte";
import CatalogEditor from "@/vcen/editor/comps/CatalogEditor.svelte";
const cat = {
@ -21,184 +19,11 @@
</script>
<div>
<div class="h-full">
<CatalogEditor cat={cat} />
</div>
<style>
/* ── Utilidades globales del editor ─────────────────────────────────── */
:global(.field-input) {
width: 100%;
background: #111827;
border: 1px solid #1f2937;
border-radius: 0.375rem;
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
color: #e2e8f0;
outline: none;
transition: border-color 120ms;
font-family: inherit;
}
:global(.field-input:focus) {
border-color: #4f46e5;
}
:global(.field-input.border-red-800) {
border-color: #991b1b;
}
:global(select.field-input) {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
padding-right: 1.75rem;
}
:global(textarea.field-input) {
resize: vertical;
}
:global(.section-title) {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6b7280;
padding-bottom: 0.5rem;
border-bottom: 1px solid #1f2937;
}
:global(.section-title-inline) {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6b7280;
}
/* ── Editor root ─────────────────────────────────────────────────────── */
.editor {
display: flex;
height: 100vh;
background: #0a0f1a;
color: #e2e8f0;
font-family: 'IBM Plex Sans', system-ui, sans-serif;
}
/* Nav */
.editor-nav {
width: 11rem;
shrink: 0;
border-right: 1px solid #1e293b;
display: flex;
flex-direction: column;
background: #080d17;
}
.editor-nav-header {
padding: 1.25rem 1rem 1rem;
border-bottom: 1px solid #1e293b;
display: flex;
align-items: baseline;
gap: 0.375rem;
}
.editor-logo {
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: 0.1em;
color: #6366f1;
}
.editor-logo-sub {
font-size: 0.6875rem;
color: #334155;
letter-spacing: 0.05em;
}
/* Tabs */
.editor-tabs {
list-style: none;
margin: 0;
padding: 0.5rem 0;
flex: 1;
}
.editor-tab {
width: 100%;
text-align: left;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
color: #475569;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: color 120ms, background 120ms;
border-left: 2px solid transparent;
}
.editor-tab:hover {
color: #94a3b8;
background: rgb(255 255 255 / 0.02);
}
.editor-tab-active {
color: #a5b4fc;
border-left-color: #6366f1;
background: rgb(99 102 241 / 0.08);
}
.editor-tab-error {
font-size: 0.625rem;
padding: 0.1rem 0.4rem;
border-radius: 9999px;
background: rgb(239 68 68 / 0.2);
color: #fca5a5;
font-weight: 600;
}
/* Footer */
.editor-nav-footer {
padding: 0.75rem 1rem;
border-top: 1px solid #1e293b;
}
.editor-status {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.6875rem;
}
.editor-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
shrink: 0;
}
.editor-status-error { color: #f87171; }
.editor-status-error .editor-status-dot { background: #ef4444; }
.editor-status-warn { color: #fbbf24; }
.editor-status-warn .editor-status-dot { background: #f59e0b; }
.editor-status-ok { color: #4ade80; }
.editor-status-ok .editor-status-dot { background: #22c55e; }
/* Main */
.editor-main {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>

@ -0,0 +1,345 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte'
import type { AttDomain } from '@/vcen/lib'
import RuleList from './RuleList.svelte'
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 }
})
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)
}
// Domain kind change
const DOMAIN_KINDS = [
{ kind: 'string', label: 'Texto' },
{ kind: 'number', label: 'Número' },
{ kind: 'currency', label: 'Moneda' },
{ kind: 'boolean', label: 'Booleano' },
{ kind: 'option', label: 'Opción' },
{ kind: 'date', label: 'Fecha' },
{ kind: 'time', label: 'Hora' },
{ kind: 'datetime', label: 'Fecha y hora' },
] as const
function onDomainKindChange(kind: string) {
if (!ctx) return
editor.updateAttDomain(ctx.objId, ctx.secId, ctx.attId, defaultDomain(kind as AttDomain['kind']))
}
function defaultDomain(kind: AttDomain['kind']): AttDomain {
switch (kind) {
case 'option': return { kind, mode: 'list', list: [] }
default: return { kind, mode: 'input' } as AttDomain
}
}
// Opts
const allOpts = $derived(editor.cat.opts)
const attOptIds = $derived.by(() => {
if (!ctx) return new Set<string>()
const d = ctx.att.value.domain
if (d.kind !== 'option' || d.mode !== 'list') return new Set<string>()
return new Set(((d as any).list ?? []).map((ao: any) => ao.optId as string))
})
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)
}
// Rules
function ensureRules() {
if (!ctx || ctx.att.rules) return
editor.setAttRuleSet(ctx.objId, ctx.secId, ctx.attId, { rules: [] })
}
function clearRules() {
if (!ctx) return
editor.setAttRuleSet(ctx.objId, ctx.secId, ctx.attId, undefined)
}
</script>
{#if ctx}
{@const { att, objId, secId, attId, container } = ctx}
<div class="p-6 max-w-2xl flex flex-col gap-6">
<!-- Header -->
<div class="flex items-center justify-between">
<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>
<!-- Identificación -->
<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={att.name}
oninput={e => patch('name', e.currentTarget.value)}
placeholder="Color, Suelo, Material..."
class="field-input" />
</div>
</section>
<!-- Tipo de dominio -->
<section class="flex flex-col gap-3">
<h3 class="section-title">Tipo</h3>
<div class="flex gap-2 flex-wrap">
{#each DOMAIN_KINDS as { kind, label }}
<button
onclick={() => onDomainKindChange(kind)}
class="px-3 py-1.5 text-xs rounded border transition-colors
{att.value.domain.kind === kind
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
>{label}</button>
{/each}
</div>
<!-- Config extra según kind -->
{#if att.value.domain.kind === 'string'}
{@const d = att.value.domain as any}
<div class="flex flex-col gap-2">
<label class="text-xs text-gray-500">Modo</label>
<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 })}
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'}"
>{mode}</button>
{/each}
</div>
{#if d.mode === 'list'}
<textarea
value={(d.list ?? []).join('\n')}
oninput={e => editor.updateAttDomain(objId, secId, attId,
{ kind: 'string', mode: 'list',
list: e.currentTarget.value.split('\n').map((s:string) => s.trim()).filter(Boolean) })}
placeholder="uno por línea"
rows={4} class="field-input"
></textarea>
{:else if d.mode === 'pattern'}
<input type="text" value={d.pattern ?? ''}
oninput={e => editor.updateAttDomain(objId, secId, attId,
{ 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,
{ kind: 'string', mode: 'fixed', fixed: e.currentTarget.value })}
class="field-input" />
{/if}
</div>
{:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'}
{@const d = att.value.domain as any}
<div class="flex flex-col gap-2">
<label class="text-xs text-gray-500">Modo</label>
<div class="flex gap-2 flex-wrap">
{#each ['input', 'range', 'step', 'list', 'fixed'] as mode}
<button
onclick={() => editor.updateAttDomain(objId, secId, attId,
{ 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
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600'}"
>{mode}</button>
{/each}
</div>
{#if d.mode === 'range' || d.mode === 'step'}
<div class="grid grid-cols-3 gap-2">
<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) })}
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) })}
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) })}
class="field-input" />
</div>
{/if}
</div>
{:else if d.mode === 'fixed'}
<input type="number" value={d.fixed ?? 0}
oninput={e => editor.updateAttDomain(objId, secId, attId,
{ 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'}
<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>
{#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}
</div>
{/if}
</div>
{/if}
</section>
<!-- Valor por defecto -->
<section class="flex flex-col gap-3">
<h3 class="section-title">Valor por defecto</h3>
{#if att.value.domain.kind === 'boolean'}
<div class="flex gap-2">
{#each [['true', 'Sí (true)'], ['false', 'No (false)']] as [val, label]}
<button
onclick={() => patch('default', val === 'true')}
class="flex-1 py-2 text-xs rounded border transition-colors
{String(att.value.value) === val
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
>{label}</button>
{/each}
</div>
{:else if att.value.domain.kind === 'option'}
{@const d = att.value.domain as any}
<select
onchange={e => patch('default', e.currentTarget.value || null)}
class="field-input"
>
<option value="">— sin valor por defecto —</option>
{#each (d.list ?? []) as ao (ao.optId)}
{@const opt = allOpts.find(o => o.id === ao.optId)}
{#if opt}
<option value={opt.id} selected={att.value.value === opt.id}>
{String(opt.value.value)}
</option>
{/if}
{/each}
</select>
{:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'}
<input type="number" value={String(att.value.value ?? 0)}
oninput={e => patch('default', Number(e.currentTarget.value))}
class="field-input" />
{:else if att.value.domain.kind === 'date'}
<input type="date" value={String(att.value.value ?? '')}
oninput={e => patch('default', e.currentTarget.value)}
class="field-input" />
{:else if att.value.domain.kind === 'time'}
<input type="time" value={String(att.value.value ?? '')}
oninput={e => patch('default', e.currentTarget.value)}
class="field-input" />
{:else if att.value.domain.kind === 'datetime'}
<input type="datetime-local" value={String(att.value.value ?? '')}
oninput={e => patch('default', e.currentTarget.value)}
class="field-input" />
{:else}
<input type="text" value={String(att.value.value ?? '')}
oninput={e => patch('default', e.currentTarget.value)}
class="field-input" />
{/if}
</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">
({att.rules?.rules?.length ?? 0})
</span>
</span>
{#if att.rules}
<button onclick={clearRules}
class="text-xs text-red-600 hover:text-red-400 transition-colors normal-case font-normal">
Eliminar todas
</button>
{:else}
<button onclick={ensureRules}
class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors normal-case font-normal">
+ Añadir reglas
</button>
{/if}
</div>
{#if att.rules && container}
<RuleList
ruleSet={att.rules}
{container}
catOpts={editor.cat.opts}
{editor}
/>
{:else if !att.rules}
<p class="text-xs text-gray-700 italic">Sin reglas</p>
{/if}
</section>
</div>
{/if}

@ -0,0 +1,119 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte'
import AttDetail from './AttDetail.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const sel = $derived(editor.selection)
const context = $derived.by(() => {
if (sel.kind !== 'sec' && sel.kind !== 'att') return null
const objId = sel.objId
const secId = 'secId' in sel ? sel.secId : undefined
if (!secId) return null
const sec = editor.findSec(objId, secId)
if (!sec) return null
return { objId, secId, sec }
})
const activeAttId = $derived(sel.kind === 'att' ? 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',
}
</script>
{#if context}
{@const { objId, secId, sec } = context}
<div class="flex h-full">
<!-- Lista de atts -->
<div class="w-56 shrink-0 border-r border-gray-800 flex flex-col">
<!-- Header sec editable -->
<div class="px-4 py-3 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={() => editor.addAtt(objId, secId)}
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={() => editor.addAtt(objId, secId)}
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 = activeAttId === att.id}
{@const hasRules = !!(att.rules?.rules?.length)}
<div
role="button" tabindex="0"
onclick={() => editor.select({ kind: 'att', objId, secId, attId: att.id })}
onkeydown={e => e.key === 'Enter' && editor.select({ kind: 'att', 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 hasRules}
<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, 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}
</div>
</div>
<!-- Detalle del att activo -->
<div class="flex-1 overflow-auto">
{#if sel.kind === 'att'}
<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}

@ -1,365 +0,0 @@
<script lang="ts">
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte';
import type { AttDomain , Obj, Sec } from '@/vcen/lib';
import RuleSetEditor from './RuleSetEditor.svelte';
interface Props { editor: CatalogEditorStore; }
let { editor }: Props = $props();
// ── Contexto activo ───────────────────────────────────────────────────────
const context = $derived.by(() => {
const sel = editor.selection;
if (sel.kind !== 'att') return null;
const { objId, secId, attId } = sel;
const att = editor.findAtt(objId, secId, attId);
if (!att) return null;
let container: Obj | Sec | null = null;
if (secId) container = editor.findSec(objId, secId) ?? null;
else if (objId) container = editor.findObj(objId) ?? null;
return { att, objId, secId, attId, container };
});
// ── Nombre ────────────────────────────────────────────────────────────────
let nameInput = $state('');
$effect(() => { if (context) nameInput = context.att.name; });
function onNameBlur() {
if (!context || nameInput === context.att.name) return;
editor.renameAtt(context.objId, context.secId, context.attId, nameInput.trim() || context.att.name);
}
// ── Dominio ───────────────────────────────────────────────────────────────
const DOMAIN_KINDS = [
{ kind: 'string', label: 'Texto' },
{ kind: 'number', label: 'Número' },
{ kind: 'currency', label: 'Moneda' },
{ kind: 'boolean', label: 'Booleano' },
{ kind: 'option', label: 'Opción' },
{ kind: 'date', label: 'Fecha' },
{ kind: 'time', label: 'Hora' },
{ kind: 'datetime', label: 'Fecha y hora' },
] as const;
function onDomainKindChange(kind: string) {
if (!context) return;
editor.updateAttDomain(context.objId, context.secId, context.attId,
defaultDomainForKind(kind as AttDomain['kind']));
}
// ── Valor por defecto ─────────────────────────────────────────────────────
let defaultVal = $state<unknown>('');
$effect(() => { if (context) defaultVal = context.att.value.value; });
function saveDefault(raw: unknown) {
if (!context) return;
const kind = context.att.value.domain.kind;
let v = raw;
if (kind === 'number' || kind === 'currency') v = Number(raw) || 0;
if (kind === 'boolean') v = raw === 'true' || raw === true;
editor.updateAttDefaultValue(context.objId, context.secId, context.attId, v);
defaultVal = v;
}
// ── Opts para dominio option ──────────────────────────────────────────────
const availableOpts = $derived(editor.cat.opts);
const attOptIds = $derived.by(() => {
if (!context) return new Set<string>();
const d = context.att.value.domain;
if (d.kind !== 'option' || d.mode !== 'list') return new Set<string>();
return new Set(((d as any).list ?? []).map((ao: any) => ao.optId as string));
});
function toggleAttOpt(optId: string) {
if (!context) return;
if (attOptIds.has(optId)) editor.removeAttOpt(context.objId, context.secId, context.attId, optId as any);
else editor.addAttOpt (context.objId, context.secId, context.attId, optId as any);
}
// ── RuleSet ───────────────────────────────────────────────────────────────
const ruleSet = $derived(context?.att.rules ?? null);
function ensureRuleSet() {
if (!context || context.att.rules) return;
editor.setAttRuleSet(context.objId, context.secId, context.attId, { rules: [] });
}
function removeRuleSet() {
if (!context) return;
editor.setAttRuleSet(context.objId, context.secId, context.attId, undefined);
}
</script>
{#if !context}
<div class="flex items-center justify-center h-full text-sm text-gray-600">
Selecciona un atributo
</div>
{:else}
{@const { att, objId, secId, attId } = context}
<div class="flex flex-col h-full overflow-y-auto">
<!-- ── Cabecera ───────────────────────────────────────────────────── -->
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-800 shrink-0">
<div class="flex flex-col gap-0.5">
<span class="text-[11px] text-gray-600 uppercase tracking-wider">Atributo</span>
<span class="text-sm font-semibold text-gray-100">{att.name}</span>
</div>
<button
onclick={() => editor.removeAtt(objId, secId, attId)}
class="text-xs text-red-600 hover:text-red-400 transition-colors"
>Eliminar</button>
</div>
<!-- ── Cuerpo ─────────────────────────────────────────────────────── -->
<div class="flex flex-col gap-6 px-5 py-5">
<!-- Nombre -->
<div class="flex flex-col gap-1.5">
<label class="section-title-inline">Nombre</label>
<input
type="text"
bind:value={nameInput}
onblur={onNameBlur}
onkeydown={e => { if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur(); }}
class="field-input"
/>
</div>
<!-- Tipo de dominio -->
<div class="flex flex-col gap-1.5">
<label class="section-title-inline">Tipo</label>
<select
value={att.value.domain.kind}
onchange={e => onDomainKindChange(e.currentTarget.value)}
class="field-input"
>
{#each DOMAIN_KINDS as { kind, label }}
<option value={kind}>{label}</option>
{/each}
</select>
</div>
<!-- Config extra por dominio -->
{#if att.value.domain.kind === 'string'}
{@const d = att.value.domain as any}
<div class="flex flex-col gap-1.5">
<label class="section-title-inline">Modo texto</label>
<select value={d.mode}
onchange={e => editor.updateAttDomain(objId, secId, attId,
{ kind: 'string', mode: e.currentTarget.value as any })}
class="field-input">
<option value="input">Libre</option>
<option value="list">Lista de valores</option>
<option value="pattern">Patrón (regex)</option>
<option value="fixed">Fijo</option>
</select>
{#if d.mode === 'list'}
<textarea
value={(d.list ?? []).join('\n')}
oninput={e => editor.updateAttDomain(objId, secId, attId,
{ kind: 'string', mode: 'list',
list: e.currentTarget.value.split('\n').map((s:string) => s.trim()).filter(Boolean) })}
placeholder="uno por línea"
rows={4}
class="field-input"
></textarea>
{:else if d.mode === 'pattern'}
<input type="text" value={d.pattern ?? ''}
oninput={e => editor.updateAttDomain(objId, secId, attId,
{ 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,
{ kind: 'string', mode: 'fixed', fixed: e.currentTarget.value })}
class="field-input" />
{/if}
</div>
{:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'}
{@const d = att.value.domain as any}
<div class="flex flex-col gap-1.5">
<label class="section-title-inline">Modo numérico</label>
<select value={d.mode}
onchange={e => editor.updateAttDomain(objId, secId, attId,
{ kind: att.value.domain.kind, mode: e.currentTarget.value as any } as any)}
class="field-input">
<option value="input">Libre</option>
<option value="range">Rango</option>
<option value="step">Pasos</option>
<option value="list">Lista</option>
<option value="fixed">Fijo</option>
</select>
{#if d.mode === 'range' || d.mode === 'step'}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-[11px] 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) })}
class="field-input" />
</div>
<div class="flex flex-col gap-1">
<span class="text-[11px] 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) })}
class="field-input" />
</div>
</div>
{#if d.mode === 'step'}
<div class="flex flex-col gap-1">
<span class="text-[11px] 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) })}
class="field-input" />
</div>
{/if}
{:else if d.mode === 'fixed'}
<input type="number" value={d.fixed ?? 0}
oninput={e => editor.updateAttDomain(objId, secId, attId,
{ 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'}
<div class="flex flex-col gap-2">
<span class="section-title-inline">
Opciones disponibles
<span class="normal-case text-gray-700 ml-1">
({attOptIds.size} de {availableOpts.length})
</span>
</span>
{#if availableOpts.length === 0}
<p class="text-xs text-gray-600 italic">
No hay opciones globales — créalas en "Opciones globales"
</p>
{:else}
<div class="flex flex-col gap-0.5 rounded border border-gray-800 bg-gray-900 p-2">
{#each availableOpts as opt (opt.id)}
{@const checked = attOptIds.has(opt.id)}
<label class="flex items-center gap-2.5 px-2 py-1.5 rounded
hover:bg-gray-800 cursor-pointer transition-colors group">
<input type="checkbox" {checked}
onchange={() => toggleAttOpt(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-[11px] text-gray-600">
{editor.cat.optCategories.find(c => c.id === opt.categoryId)?.name ?? ''}
</span>
{/if}
</label>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Valor por defecto -->
<div class="flex flex-col gap-1.5">
<label class="section-title-inline">Valor por defecto</label>
{#if att.value.domain.kind === 'boolean'}
<select value={String(defaultVal)}
onchange={e => saveDefault(e.currentTarget.value)}
class="field-input">
<option value="true">Sí (true)</option>
<option value="false">No (false)</option>
</select>
{:else if att.value.domain.kind === 'option'}
{@const d = att.value.domain as any}
<select value={String(defaultVal ?? '')}
onchange={e => saveDefault(e.currentTarget.value)}
class="field-input">
<option value="">— ninguno —</option>
{#each (d.list ?? []) as ao (ao.optId)}
{@const opt = availableOpts.find(o => o.id === ao.optId)}
{#if opt}
<option value={opt.id}>{String(opt.value.value)}</option>
{/if}
{/each}
</select>
{:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'}
<input type="number" value={String(defaultVal ?? 0)}
oninput={e => saveDefault(e.currentTarget.value)}
class="field-input" />
{:else if att.value.domain.kind === 'date'}
<input type="date" value={String(defaultVal ?? '')}
oninput={e => saveDefault(e.currentTarget.value)}
class="field-input" />
{:else if att.value.domain.kind === 'time'}
<input type="time" value={String(defaultVal ?? '')}
oninput={e => saveDefault(e.currentTarget.value)}
class="field-input" />
{:else if att.value.domain.kind === 'datetime'}
<input type="datetime-local" value={String(defaultVal ?? '')}
oninput={e => saveDefault(e.currentTarget.value)}
class="field-input" />
{:else}
<input type="text" value={String(defaultVal ?? '')}
oninput={e => saveDefault(e.currentTarget.value)}
class="field-input" />
{/if}
</div>
<!-- RuleSet -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between pb-2 border-b border-gray-800">
<span class="section-title" style="border:none;padding:0">Reglas</span>
{#if ruleSet}
<button onclick={removeRuleSet}
class="text-xs text-red-600 hover:text-red-400 transition-colors">
✕ Eliminar todas
</button>
{:else}
<button onclick={ensureRuleSet}
class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">
+ Añadir reglas
</button>
{/if}
</div>
{#if ruleSet && context.container}
<RuleSetEditor
{ruleSet}
container={context.container}
catOpts={editor.cat.opts}
{editor}
/>
{/if}
</div>
</div>
</div>
{/if}
<script lang="ts" module>
import type { AttDomain as AD } from './att';
function defaultDomainForKind(kind: AD['kind']): AD {
switch (kind) {
case 'string': return { kind, mode: 'input' };
case 'number': return { kind, mode: 'input' };
case 'currency': return { kind, mode: 'input' };
case 'boolean': return { kind, mode: 'input' };
case 'option': return { kind, mode: 'list', list: [] };
case 'date': return { kind, mode: 'input' };
case 'time': return { kind, mode: 'input' };
case 'datetime': return { kind, mode: 'input' };
}
}
</script>

@ -0,0 +1,51 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const cat = $derived(editor.cat)
const stats = $derived({
objs: cat.objs.length,
secs: cat.objs.reduce((n, o) => n + o.secs.length, 0),
atts: cat.objs.reduce((n, o) =>
n + o.atts.length + o.secs.reduce((m, s) => m + s.atts.length, 0), 0),
opts: cat.opts.length,
})
</script>
<div class="p-6 max-w-2xl flex flex-col gap-6">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-200">Configuración del catálogo</h2>
<span class="text-xs text-gray-700 font-mono">{cat.id}</span>
</div>
<!-- Stats -->
<div class="grid grid-cols-4 gap-3">
{#each [
{ label: 'Objetos', value: stats.objs },
{ label: 'Secciones', value: stats.secs },
{ label: 'Atributos', value: stats.atts },
{ label: 'Opciones', value: stats.opts },
] 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 del catálogo</label>
<input type="text" value={cat.name}
oninput={e => editor.renameCat(e.currentTarget.value)}
placeholder="Mi catálogo"
class="field-input" />
</div>
</section>
</div>

@ -1,59 +0,0 @@
<script lang="ts">
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte.js';
interface Props { editor: CatalogEditorStore; }
let { editor }: Props = $props();
const cat = $derived(editor.cat);
let nameInput = $state('');
$effect(() => { nameInput = cat.name; });
function onNameBlur() {
if (nameInput === cat.name) return;
editor.renameCat(nameInput);
}
const inputCls = `w-full rounded border border-gray-700 bg-gray-900 px-3 py-2
text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`;
const labelCls = `text-xs font-semibold uppercase tracking-wider text-gray-500`;
</script>
<div class="flex flex-col gap-6 p-5">
<h2 class="text-base font-semibold text-gray-100">Catálogo</h2>
<div class="flex flex-col gap-1.5">
<label class={labelCls}>Nombre</label>
<input type="text" bind:value={nameInput} onblur={onNameBlur} class={inputCls} />
</div>
<div class="flex flex-col gap-1.5">
<span class={labelCls}>Resumen</span>
<div class="rounded border border-gray-800 bg-gray-900 p-4 grid grid-cols-2 gap-3 text-sm">
<div class="flex flex-col gap-0.5">
<span class="text-2xl font-bold text-gray-100">{cat.objs.length}</span>
<span class="text-gray-600">Objetos</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-2xl font-bold text-gray-100">
{cat.objs.reduce((n, o) => n + o.secs.length, 0)}
</span>
<span class="text-gray-600">Secciones</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-2xl font-bold text-gray-100">
{cat.objs.reduce((n, o) =>
n + o.atts.length + o.secs.reduce((m, s) => m + s.atts.length, 0), 0
)}
</span>
<span class="text-gray-600">Atributos</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-2xl font-bold text-gray-100">{cat.opts.length}</span>
<span class="text-gray-600">Opciones globales</span>
</div>
</div>
</div>
</div>

@ -1,76 +1,23 @@
<script lang="ts">
import type { Cat } from '@/vcen/lib';
import { createCatalogEditorStore } from '../stores/catalog-editor.svelte';
import CatalogTree from './CatalogTree.svelte';
import CatPanel from './CatPanel.svelte';
import ObjPanel from './ObjPanel.svelte';
import SecPanel from './SecPanel.svelte';
import AttPanel from './AttPanel.svelte';
import OptPanel from './OptPanel.svelte';
interface Props {
cat: Cat;
onsave?: (cat: Cat) => void;
oncancel?: () => void;
}
let { cat, onsave, oncancel }: Props = $props();
const editor = createCatalogEditorStore(cat);
const sel = $derived(editor.selection);
import { setContext } from 'svelte'
import { createCatalogEditorStore } from './catalog-editor.svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte'
import type { Cat } from '@/vcen/lib'
import CatalogLayout from './CatalogLayout.svelte'
let { cat, onsave, oncancel }: {
cat: Cat
onsave?: (cat: Cat) => void
oncancel?: () => void
} = $props()
const editor = createCatalogEditorStore(cat)
setContext<CatalogEditorStore>('catalog-editor', editor)
</script>
<div class="editor">
<!-- Árbol izquierdo -->
<div class="editor-tree">
<CatalogTree {editor} />
</div>
<!-- Panel derecho -->
<div class="editor-main">
<!-- Toolbar -->
<div class="editor-toolbar">
<span class="editor-breadcrumb">
{#if sel.kind === 'cat'} Catálogo
{:else if sel.kind === 'opts'} Opciones globales
{:else if sel.kind === 'obj'} Objeto
{:else if sel.kind === 'sec'} Sección
{:else if sel.kind === 'att'} Atributo
{/if}
</span>
<div class="flex gap-2">
{#if oncancel}
<button onclick={oncancel} class="editor-btn">Cancelar</button>
{/if}
<button
onclick={() => { onsave?.(editor.getCat()); editor.markClean(); }}
disabled={!editor.dirty}
class="editor-btn editor-btn-primary"
class:editor-btn-disabled={!editor.dirty}
>Guardar</button>
</div>
</div>
<!-- Contenido -->
<div class="editor-panel">
{#if sel.kind === 'cat'} <CatPanel {editor} />
{:else if sel.kind === 'opts'} <OptPanel {editor} />
{:else if sel.kind === 'obj'} <ObjPanel {editor} />
{:else if sel.kind === 'sec'} <SecPanel {editor} />
{:else if sel.kind === 'att'} <AttPanel {editor} />
{/if}
</div>
</div>
</div>
<CatalogLayout {onsave} {oncancel} />
<style>
/* ── Clases globales (field-input, section-title, etc.) ─────────────────
Deben vivir en el componente raíz para que los hijos las hereden. */
:global(.field-input) {
width: 100%;
background: #111827;
@ -83,8 +30,8 @@
transition: border-color 120ms;
font-family: inherit;
}
:global(.field-input:focus) { border-color: #4f46e5; }
:global(.field-input:focus) { border-color: #4f46e5; }
:global(.field-input.invalid) { border-color: #991b1b; }
:global(select.field-input) {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
@ -92,10 +39,9 @@
background-position: right 0.5rem center;
padding-right: 1.75rem;
}
:global(textarea.field-input) { resize: vertical; }
:global(textarea.field-input) { resize: vertical; }
:global(.section-title) {
display: block;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
@ -103,9 +49,9 @@
color: #6b7280;
padding-bottom: 0.5rem;
border-bottom: 1px solid #1f2937;
margin-bottom: 0;
}
:global(.section-title-inline) {
display: block;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
@ -113,80 +59,14 @@
color: #6b7280;
}
/* ── Layout ──────────────────────────────────────────────────────────── */
.editor {
:global(.catalog-editor) {
display: flex;
height: 100%;
height: 100vh;
background: #0a0f1a;
color: #e2e8f0;
font-size: 0.8125rem;
font-family: 'IBM Plex Sans', system-ui, sans-serif;
overflow: hidden;
}
.editor-tree {
width: 15rem;
flex-shrink: 0;
border-right: 1px solid #1e293b;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid #1e293b;
background: #080d17;
flex-shrink: 0;
}
.editor-breadcrumb {
font-size: 0.75rem;
color: #475569;
}
.editor-panel {
flex: 1;
overflow-y: auto;
}
/* ── Botones ─────────────────────────────────────────────────────────── */
.editor-btn {
padding: 0.3rem 0.875rem;
font-size: 0.8125rem;
border-radius: 0.375rem;
border: 1px solid #1f2937;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: color 120ms, border-color 120ms, background 120ms;
font-family: inherit;
}
.editor-btn:hover { color: #e2e8f0; border-color: #334155; }
.editor-btn-primary {
background: #4f46e5;
border-color: #4f46e5;
color: #fff;
}
.editor-btn-primary:hover { background: #4338ca; border-color: #4338ca; }
.editor-btn-disabled {
background: #1f2937 !important;
border-color: #1f2937 !important;
color: #374151 !important;
cursor: not-allowed !important;
}
</style>

@ -0,0 +1,66 @@
<script lang="ts">
import type { Cat } from '@/vcen/lib'
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte'
import CatalogSidebar from './CatalogSidebar.svelte'
import CatMetaPanel from './CatMetaPanel.svelte'
import ObjsPanel from './ObjsPanel.svelte'
import OptsPanel from './OptsPanel.svelte'
let { onsave, oncancel }: {
onsave?: (cat : Cat) => void
oncancel?: () => void
} = $props()
const editor = getContext<CatalogEditorStore>('catalog-editor')
const panel = $derived(editor.activePanel)
</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}
<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">
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>
</div>
</div>

@ -0,0 +1,33 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const nav = [
{ panel: 'cat', label: 'Catálogo', icon: '⬡' },
{ panel: 'objs', label: 'Objetos', icon: '▣' },
{ panel: 'opts', label: 'Opciones', icon: '◈' },
] as const
</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>
{#each nav as item}
<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>

@ -1,268 +0,0 @@
<script lang="ts">
import type { CatalogEditorStore, Selection } from '../stores/catalog-editor.svelte.ts';
import type { ObjID, SecID, AttID } from '@/vcen/lib';
interface Props { editor: CatalogEditorStore; }
let { editor }: Props = $props();
const cat = $derived(editor.cat);
const selection = $derived(editor.selection);
// ── Nodos expandidos ────────────────────────────────────────────────────
// $state independiente — se inicializa una vez con los ids actuales del cat.
// Cuando se añaden nuevos objs/secs se actualizan manualmente en addObj/addSec.
let expandedObjs = $state<Set<ObjID>>(new Set(editor.cat.objs.map(o => o.id)));
let expandedSecs = $state<Set<string>>(new Set(
editor.cat.objs.flatMap(o => o.secs.map(s => SEP(o.id, s.id)))
));
function toggleObj(objId: ObjID) {
const next = new Set(expandedObjs);
next.has(objId) ? next.delete(objId) : next.add(objId);
expandedObjs = next;
}
// Clave compuesta para secciones — misma lógica que nodeSeparator en engine.ts
function SEP(a: string, b: string) { return `${a}/${b}`; }
function toggleSec(objId: ObjID, secId: SecID) {
const key = SEP(objId, secId);
const next = new Set(expandedSecs);
next.has(key) ? next.delete(key) : next.add(key);
expandedSecs = next;
}
// ── Selección helpers ───────────────────────────────────────────────────
function isSelected(s: Selection): boolean {
const sel = selection;
if (sel.kind !== s.kind) return false;
if (s.kind === 'cat' || s.kind === 'opts') return true;
if (s.kind === 'obj') return sel.kind === 'obj' && sel.objId === s.objId;
if (s.kind === 'sec') return sel.kind === 'sec' && sel.objId === s.objId && sel.secId === s.secId;
if (s.kind === 'att') return sel.kind === 'att' && sel.attId === s.attId;
return false;
}
const selBase = `flex items-center gap-1.5 w-full rounded px-2 py-1 text-sm text-left
transition-colors cursor-pointer select-none`;
const selInact = `${selBase} text-gray-300 hover:bg-gray-800`;
const selAct = `${selBase} bg-blue-600 text-white`;
function cls(s: Selection) { return isSelected(s) ? selAct : selInact; }
// ── Add helpers con expansión automática ────────────────────────────────
function addObj() {
const id = editor.addObj();
expandedObjs = new Set([...expandedObjs, id]);
}
function addSec(objId: ObjID) {
const id = editor.addSec(objId);
expandedSecs = new Set([...expandedSecs, SEP(objId, id)]);
}
function getCatAttSel(attId: AttID): Selection {
return { kind: 'att', objId: '' as ObjID, attId };
}
</script>
<!-- ─────────────────────────────────────────────────────────────────────── -->
<!-- PANEL IZQUIERDO -->
<!-- ─────────────────────────────────────────────────────────────────────── -->
<nav class="flex flex-col h-full bg-gray-950 border-r border-gray-800 select-none">
<!-- Header del catálogo -->
<div class="flex items-center justify-between px-3 py-2.5 border-b border-gray-800">
<button
class={cls({ kind: 'cat' })}
onclick={() => editor.select({ kind: 'cat' })}
>
<span class="text-xs text-gray-500">📁</span>
<span class="font-medium truncate">{cat.name}</span>
</button>
</div>
<!-- Árbol scrolleable -->
<div class="flex-1 overflow-y-auto py-1">
<!-- Opts globales -->
<button
class="{cls({ kind: 'opts' })} mx-1 my-0.5"
onclick={() => editor.select({ kind: 'opts' })}
>
<span class="text-xs">🏷️</span>
<span>Opciones globales</span>
<span class="ml-auto text-xs opacity-50">{cat.opts.length}</span>
</button>
<!-- Atts de catálogo (si los hay) -->
{#if cat.atts.length > 0}
<div class="mt-1 mb-0.5 px-3">
<span class="text-xs font-semibold uppercase tracking-wider text-gray-600">
Atributos globales
</span>
</div>
{#each cat.atts as att (att.id)}
{@const catAttSel = getCatAttSel(att.id)}
<button
class="{cls(catAttSel)} mx-1 pl-5"
onclick={() => editor.select(catAttSel)}
>
<span class="text-xs opacity-50">⬡</span>
<span class="truncate">{att.name}</span>
</button>
{/each}
{/if}
<!-- Divider + label objetos -->
<div class="flex items-center justify-between px-3 mt-2 mb-0.5">
<span class="text-xs font-semibold uppercase tracking-wider text-gray-600">
Objetos
</span>
<button
onclick={addObj}
class="text-xs text-gray-500 hover:text-blue-400 transition-colors"
title="Añadir objeto"
>+</button>
</div>
<!-- Lista de objs -->
{#each cat.objs as obj (obj.id)}
{@const objExpanded = expandedObjs.has(obj.id)}
<!-- Obj row -->
<div class="flex items-center gap-0.5 mx-1 my-0.5">
<!-- Chevron expand/collapse -->
<button
onclick={() => toggleObj(obj.id)}
class="shrink-0 w-5 h-5 flex items-center justify-center rounded
text-gray-600 hover:text-gray-300 transition-colors"
>
<span class="text-xs">{objExpanded ? '▾' : '▸'}</span>
</button>
<!-- Nombre obj -->
<button
class="flex-1 {cls({ kind: 'obj', objId: obj.id })} py-0.5"
onclick={() => editor.select({ kind: 'obj', objId: obj.id })}
>
<span class="text-xs">◈</span>
<span class="truncate">{obj.name}</span>
</button>
</div>
<!-- Contenido del obj (colapsable) -->
{#if objExpanded}
<div class="pl-5">
<!-- Atts directos del obj -->
{#each obj.atts as att (att.id)}
<button
class="{cls({ kind: 'att', objId: obj.id, attId: att.id })} mx-1 my-0.5 pl-3"
onclick={() => editor.select({ kind: 'att', objId: obj.id, attId: att.id })}
>
<span class="text-xs opacity-50">⬡</span>
<span class="truncate">{att.name}</span>
</button>
{/each}
<!-- Botón añadir att directo -->
<button
onclick={() => editor.addAtt(obj.id, undefined)}
class="mx-1 my-0.5 pl-3 flex items-center gap-1 text-xs text-gray-600
hover:text-blue-400 transition-colors w-full py-0.5"
>
<span>+</span> att
</button>
<!-- Secs -->
{#each obj.secs as sec (sec.id)}
{@const secKey = SEP(obj.id, sec.id)}
{@const secExpanded = expandedSecs.has(secKey)}
<!-- Sec row -->
<div class="flex items-center gap-0.5 mx-1 my-0.5">
<button
onclick={() => toggleSec(obj.id, sec.id)}
class="shrink-0 w-5 h-5 flex items-center justify-center rounded
text-gray-600 hover:text-gray-300 transition-colors"
>
<span class="text-xs">{secExpanded ? '▾' : '▸'}</span>
</button>
<button
class="flex-1 {cls({ kind: 'sec', objId: obj.id, secId: sec.id })} py-0.5"
onclick={() => editor.select({ kind: 'sec', objId: obj.id, secId: sec.id })}
>
<span class="text-xs opacity-60">▤</span>
<span class="truncate">{sec.name}</span>
</button>
</div>
<!-- Atts de la sec -->
{#if secExpanded}
<div class="pl-5">
{#each sec.atts as att (att.id)}
<button
class="{cls({ kind: 'att', objId: obj.id, secId: sec.id, attId: att.id })} mx-1 my-0.5 pl-3"
onclick={() => editor.select({
kind: 'att', objId: obj.id, secId: sec.id, attId: att.id
})}
>
<span class="text-xs opacity-50">⬡</span>
<span class="truncate">{att.name}</span>
</button>
{/each}
<!-- Botón añadir att en sec -->
<button
onclick={() => editor.addAtt(obj.id, sec.id)}
class="mx-1 my-0.5 pl-3 flex items-center gap-1 text-xs text-gray-600
hover:text-blue-400 transition-colors w-full py-0.5"
>
<span>+</span> att
</button>
</div>
{/if}
{/each}
<!-- Botón añadir sec -->
<button
onclick={() => addSec(obj.id)}
class="mx-1 my-0.5 pl-3 flex items-center gap-1 text-xs text-gray-600
hover:text-blue-400 transition-colors w-full py-0.5"
>
<span>+</span> sección
</button>
</div>
{/if}
{/each}
<!-- Botón añadir obj (al final de la lista) -->
{#if cat.objs.length === 0}
<button
onclick={addObj}
class="mx-2 mt-1 flex items-center gap-1.5 text-sm text-gray-600
hover:text-blue-400 transition-colors"
>
<span>+</span> Añadir primer objeto
</button>
{/if}
</div>
<!-- Footer: indicador de cambios sin guardar -->
{#if editor.dirty}
<div class="px-3 py-2 border-t border-gray-800 text-xs text-amber-400 flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block"></span>
Cambios sin guardar
</div>
{/if}
</nav>

@ -1,182 +0,0 @@
<script lang="ts">
import type { ConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
import type {CompareNode, Operand} from '../lib/condition-editor.ts';
interface Props {
node: CompareNode;
store: ConditionBuilderStore;
}
let { node, store }: Props = $props();
function opLabel(op: string): string {
const labels: Record<string, string> = {
'==': 'igual a', '!=': 'distinto de',
'>': 'mayor que', '<': 'menor que',
'>=': 'mayor o igual que', '<=': 'menor o igual que',
'date-before': 'antes de', 'date-after': 'después de', 'date-between': 'entre',
};
return labels[op] ?? op;
}
const error = $derived(store.errors.find(e => e.nodeId === node.id));
const scopeEntry = $derived(
node.left.kind === 'var' ? store.scope.find(e => e.path === node.left.path) : undefined
);
const availableOps = $derived(
node.left.kind === 'var'
? store.validOperatorsFor(node.left.path)
: (['==', '!='] as const)
);
// Bindings locales
let selectedVar = $state(node.left.kind === 'var' ? node.left.path : '');
let selectedOp = $state(node.op);
let rightValue = $state(!Array.isArray(node.right) && node.right.kind === 'literal' ? String(node.right.value ?? '') : '');
let rightLo = $state('');
let rightHi = $state('');
$effect(() => {
if (Array.isArray(node.right)) {
const [lo, hi] = node.right;
rightLo = lo.kind === 'literal' ? String(lo.value) : '';
rightHi = hi.kind === 'literal' ? String(hi.value) : '';
}
});
function onVarChange() {
store.setLeft(node.id, { kind: 'var', path: selectedVar });
const ops = store.validOperatorsFor(selectedVar);
if (!ops.includes(selectedOp as any)) {
selectedOp = ops[0];
store.setOperator(node.id, ops[0]);
}
// Reset right al cambiar variable
rightValue = '';
store.setRight(node.id, { kind: 'literal', value: '' });
}
function onOpChange() {
store.setOperator(node.id, selectedOp as any);
}
let inputError = $state('');
function onRightChange() {
const domain = scopeEntry?.domain;
let value: unknown = rightValue;
if (domain?.kind === 'number' || domain?.kind === 'currency') {
if (rightValue === '') { inputError = 'Introduce un n\u00famero'; return; }
const n = Number(rightValue);
if (isNaN(n)) { inputError = 'Debe ser un n\u00famero v\u00e1lido'; return; }
inputError = '';
value = n;
} else if (domain?.kind === 'boolean') {
inputError = '';
value = rightValue === 'true';
} else if (domain?.kind === 'date' || domain?.kind === 'time' || domain?.kind === 'datetime') {
if (rightValue === '') { inputError = 'Selecciona una fecha'; return; }
inputError = '';
} else {
inputError = '';
}
store.setRight(node.id, { kind: 'literal', value });
}
function onBetweenChange() {
const lo: Operand = { kind: 'literal', value: rightLo };
const hi: Operand = { kind: 'literal', value: rightHi };
store.setRight(node.id, [lo, hi]);
}
const baseSelect = `rounded border border-gray-300 bg-white px-2 py-1 text-sm
text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500`;
const baseInput = `rounded border border-gray-300 bg-white px-2 py-1 text-sm
text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500
min-w-0 flex-1`;
</script>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2 rounded border px-2 py-1.5
{error ? 'border-red-300 bg-red-50' : 'border-gray-200 bg-white'}">
<!-- Variable -->
<select bind:value={selectedVar} onchange={onVarChange} class="{baseSelect} flex-1 min-w-0">
<option value="" disabled>— Variable —</option>
{#each store.scope as entry (entry.path)}
<option value={entry.path}>{entry.path}</option>
{/each}
</select>
<!-- Operador -->
<select bind:value={selectedOp} onchange={onOpChange} class={baseSelect}>
{#each availableOps as op}
<option value={op}>{opLabel(op)}</option>
{/each}
</select>
<!-- Valor — renderizado según dominio -->
{#if !scopeEntry}
<input type="text" disabled placeholder="selecciona variable" class="{baseInput} opacity-40" />
{:else if node.op === 'date-between'}
<input type="date" bind:value={rightLo} oninput={onBetweenChange} class={baseInput} />
<span class="text-xs text-gray-400 shrink-0">y</span>
<input type="date" bind:value={rightHi} oninput={onBetweenChange} class={baseInput} />
{:else if scopeEntry.domain.kind === 'option'}
{@const opts = (scopeEntry.domain as any).options ?? []}
{#if opts.length > 0}
<select bind:value={rightValue} onchange={onRightChange} class="{baseSelect} flex-1 min-w-0">
<option value="" disabled>— Valor —</option>
{#each opts as opt}
<option value={opt}>{opt}</option>
{/each}
</select>
{:else}
<input type="text" bind:value={rightValue} oninput={onRightChange}
placeholder="valor" class={baseInput} />
{/if}
{:else if scopeEntry.domain.kind === 'boolean'}
<select bind:value={rightValue} onchange={onRightChange} class="{baseSelect} flex-1 min-w-0">
<option value="" disabled>— Valor —</option>
<option value="true">Sí (true)</option>
<option value="false">No (false)</option>
</select>
{:else if scopeEntry.domain.kind === 'number' || scopeEntry.domain.kind === 'currency'}
<input type="number" bind:value={rightValue} oninput={onRightChange}
placeholder="0" class={baseInput} />
{:else if scopeEntry.domain.kind === 'date'}
<input type="date" bind:value={rightValue} oninput={onRightChange} class={baseInput} />
{:else if scopeEntry.domain.kind === 'time'}
<input type="time" bind:value={rightValue} oninput={onRightChange} class={baseInput} />
{:else if scopeEntry.domain.kind === 'datetime'}
<input type="datetime-local" bind:value={rightValue} oninput={onRightChange} class={baseInput} />
{:else}
<input type="text" bind:value={rightValue} oninput={onRightChange}
placeholder="valor" class={baseInput} />
{/if}
<!-- Eliminar -->
<button onclick={() => store.remove(node.id)}
class="shrink-0 rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
title="Eliminar condición">✕</button>
</div>
{#if inputError}
<p class="text-xs text-red-500 px-1">{inputError}</p>
{:else if error}
<p class="text-xs text-red-500 px-1">{error.message}</p>
{/if}
</div>

@ -1,31 +1,244 @@
<script lang="ts">
import type { ConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
import ConditionNode from './ConditionNode.svelte';
/**
* CONDITION BUILDER
* Trabaja directamente con la API del ConditionBuilderStore (árbol de nodos).
* Sigue el patrón visual del ConditionBuilder.svelte de referencia.
*/
import type { ConditionBuilderStore } from '../stores/condition-builder.svelte.ts'
import type { ConditionNode, LogicOp, CompareOp, Operand } from '../lib/condition-editor'
interface Props {
store: ConditionBuilderStore;
onchange?: (compiled: unknown) => void;
onvalid?: (valid: boolean) => void;
let {
store,
onchange,
}: {
store: ConditionBuilderStore
onchange: () => void
} = $props()
let rawMode = $state(false)
let rawValue = $state('')
function emit() { onchange() }
// Renderiza un nodo de forma recursiva
// Los nodos son: { kind: 'logic', op, children } | { kind: 'compare', op, left, right }
function nodeIsEmpty(node: ConditionNode): boolean {
if (node.kind === 'logic') return node.children.length === 0
return false
}
const isEmpty = $derived(
store.root.kind === 'logic' && store.root.children.length === 0
)
// Scope para los selects de atributo
const scopeAttrs = $derived(
store.scope.map(e => ({ id: e.path, label: e.path.replace('/', ' / ') }))
)
function getLeftPath(node: ConditionNode): string {
if (node.kind !== 'compare') return ''
const left = node.left
if (left && typeof left === 'object' && 'kind' in left && (left as any).kind === 'var') return (left as any).path
return ''
}
function setLeft(nodeId: string, path: string) {
store.setLeft(nodeId, { kind: 'var', path } as Operand)
emit()
}
function setOp(nodeId: string, op: CompareOp) {
store.setOperator(nodeId, op)
emit()
}
function setRightLiteral(nodeId: string, value: unknown) {
store.setRight(nodeId, { kind: 'literal', value } as Operand)
emit()
}
function toggleOptRight(node: ConditionNode, optId: string, multi: boolean) {
if (node.kind !== 'compare') return
const current = Array.isArray(node.right) ? node.right : (node.right && typeof node.right === 'object' && 'kind' in (node.right as any) && (node.right as any).kind === 'literal' ? [(node.right as any)] : [])
const currentIds = current.map((o: any) => o?.value ?? o)
const next = multi
? (currentIds.includes(optId) ? currentIds.filter((v: string) => v !== optId) : [...currentIds, optId])
: [optId]
if (multi) {
store.setRight(node.id, next.map(v => ({ kind: 'literal', value: v })) as any)
} else {
store.setRight(node.id, { kind: 'literal', value: next[0] } as Operand)
}
emit()
}
let { store, onchange, onvalid }: Props = $props();
function isOptActive(node: ConditionNode, optId: string): boolean {
if (node.kind !== 'compare') return false
const r = node.right
if (!r) return false
if (Array.isArray(r)) return r.some((o: any) => o?.value === optId || o === optId)
if (typeof r === 'object' && 'kind' in r && (r as any).kind === 'literal') return (r as any).value === optId
return false
}
$effect(() => { onchange?.(store.compiled); });
$effect(() => { onvalid?.(store.isValid); });
const OPS_LABELS: Record<string, string> = {
'==': '=', '!=': '≠', '>': '>', '>=': '≥', '<': '<', '<=': '≤',
'in': 'es alguno de', '!in': 'no es ninguno de',
'contains': 'contiene', 'startsWith': 'empieza por',
}
</script>
<div class="flex flex-col gap-2">
{#if store.tautology.kind === 'always-true'}
<div class="rounded bg-amber-50 border border-amber-300 px-3 py-1.5 text-sm text-amber-700">
⚠️ Esta condición siempre es <strong>verdadera</strong>
<!-- Header con toggle raw -->
<div class="flex items-center justify-between min-h-5">
<span class="text-xs text-gray-600 uppercase tracking-wider">Condición</span>
<div class="flex items-center gap-2">
{#if !rawMode && isEmpty}
<span class="text-xs text-gray-700 italic">siempre activa</span>
{/if}
{#if store.errors.length > 0 && !rawMode}
<span class="text-xs text-amber-700 italic">{store.errors.length} error{store.errors.length > 1 ? 'es' : ''}</span>
{/if}
<button
onclick={() => {
if (!rawMode) rawValue = store.compiled ? JSON.stringify(store.compiled, null, 2) : ''
rawMode = !rawMode
}}
class="text-xs text-gray-600 hover:text-gray-400 transition-colors underline underline-offset-2"
>{rawMode ? 'visual' : 'raw'}</button>
</div>
{:else if store.tautology.kind === 'always-false'}
<div class="rounded bg-red-50 border border-red-300 px-3 py-1.5 text-sm text-red-700">
⚠️ Esta condición siempre es <strong>falsa</strong>
</div>
{/if}
</div>
{#if rawMode}
<textarea rows="4"
placeholder={'{\n "==": [{"var": "att/color"}, "rojo"]\n}'}
bind:value={rawValue}
oninput={() => {
try {
const parsed = rawValue.trim() ? JSON.parse(rawValue) : undefined
if (parsed !== undefined) store.load(parsed)
else store.reset()
emit()
} catch { /* esperar JSON válido */ }
}}
class="field-input font-mono text-xs resize-none"
spellcheck={false}
></textarea>
<p class="text-xs text-gray-700">JsonLogic. Vacío = siempre activa.</p>
<ConditionNode node={store.root} {store} depth={0} />
{:else}
<!-- Lógica raíz AND/OR -->
{#if store.root.kind === 'logic'}
{@const root = store.root}
{#if root.children.length > 1}
<div class="flex items-center gap-1.5 text-xs">
<span class="text-gray-600 shrink-0">Combinar con</span>
{#each (['and', 'or'] as const) as l}
<button
onclick={() => { store.setLogicOp(root.id, l); emit(); }}
class="px-2 py-0.5 rounded border text-xs transition-colors
{root.op === l
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600'}"
>{l.toUpperCase()}</button>
{/each}
</div>
{/if}
{#each root.children as node (node.id)}
{#if node.kind === 'compare'}
{@const leftPath = getLeftPath(node)}
{@const ops = leftPath ? store.validOperatorsFor(leftPath) : []}
{@const opts = leftPath ? store.optsFor(leftPath) : []}
{@const isMulti = node.op === 'in' || node.op === '!in'}
<div class="flex flex-col gap-1.5 rounded border border-gray-800 bg-gray-900/40 p-2">
<div class="flex items-center gap-1.5 flex-wrap">
<!-- Atributo -->
<select
value={leftPath}
onchange={e => setLeft(node.id, e.currentTarget.value)}
class="flex-1 min-w-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1 focus:outline-none focus:border-indigo-500"
>
<option value="">— atributo —</option>
{#each scopeAttrs as a}
<option value={a.id}>{a.label}</option>
{/each}
</select>
<!-- Operador -->
{#if leftPath && ops.length > 0}
<select
value={node.op}
onchange={e => setOp(node.id, e.currentTarget.value as CompareOp)}
class="shrink-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1 focus:outline-none focus:border-indigo-500"
>
{#each ops as op}
<option value={op}>{OPS_LABELS[op] ?? op}</option>
{/each}
</select>
{/if}
<!-- Valor (si no hay opts chips) -->
{#if leftPath && opts.length === 0}
<input type="text"
value={node.right && typeof node.right === 'object' && 'kind' in node.right && (node.right as any).kind === 'literal'
? String((node.right as any).value ?? '') : ''}
oninput={e => setRightLiteral(node.id, e.currentTarget.value)}
placeholder="valor..."
class="w-24 shrink-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1" />
{/if}
<button
onclick={() => { store.remove(node.id); emit(); }}
class="shrink-0 text-gray-600 hover:text-red-400 transition-colors text-xs px-1"
>✕</button>
</div>
<!-- Chips de opciones -->
{#if opts.length > 0}
<div class="flex flex-wrap gap-1 pt-0.5 pl-0.5">
{#each opts as opt}
{@const active = isOptActive(node, opt.optId)}
<button
onclick={() => toggleOptRight(node, opt.optId, isMulti)}
class="text-xs px-2 py-0.5 rounded-full border transition-colors
{active
? 'bg-indigo-600/30 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
>{opt.value ?? opt.optId}</button>
{/each}
</div>
{/if}
</div>
{/if}
{/each}
{/if}
<!-- Añadir cláusula -->
<button
onclick={() => { store.addCompare(store.root.id); emit(); }}
class="flex items-center gap-1.5 px-3 py-1.5 rounded border border-dashed
border-gray-700 text-xs text-gray-600 hover:text-indigo-400
hover:border-indigo-700 transition-colors"
>+ Añadir cláusula</button>
{#if store.tautology.kind !== 'not-tautology'}
<p class="text-xs text-amber-700 italic">
⚠ {store.tautology.kind === 'always-true' ? 'Siempre verdadera' : 'Siempre falsa'}
</p>
{/if}
{/if}
</div>

@ -1,20 +0,0 @@
<script lang="ts">
import type { ConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
import type { ConditionNode as ConditionNodeType } from '../lib/condition-editor.ts';
import LogicNodeEditor from './LogicNodeEditor.svelte';
import CompareNodeEditor from './CompareNodeEditor.svelte';
interface Props {
node: ConditionNodeType;
store: ConditionBuilderStore;
depth: number;
}
let { node, store, depth }: Props = $props();
</script>
{#if node.kind === 'logic'}
<LogicNodeEditor {node} {store} {depth} />
{:else}
<CompareNodeEditor {node} {store} />
{/if}

@ -1,64 +0,0 @@
<script lang="ts">
import type { ConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
import type {LogicNode, LogicOp} from '../lib/condition-editor.ts';
import ConditionNode from './ConditionNode.svelte';
interface Props {
node: LogicNode;
store: ConditionBuilderStore;
depth: number;
}
let { node, store, depth }: Props = $props();
let selectedOp = $state<LogicOp>(node.op);
function onOpChange() {
store.setLogicOp(node.id, selectedOp);
}
</script>
<div class="flex flex-col gap-1.5 rounded border border-gray-200 bg-gray-50 p-2">
<div class="flex items-center gap-2">
<select bind:value={selectedOp} onchange={onOpChange}
class="rounded border border-gray-300 bg-white px-2 py-1 text-sm font-medium
text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="and">Y (AND)</option>
<option value="or">O (OR)</option>
</select>
<span class="text-xs text-gray-400">se cumplen</span>
<div class="ml-auto flex gap-1">
<button
onclick={() => store.addCompare(node.id)}
class="rounded bg-blue-50 px-2 py-1 text-xs text-blue-600 hover:bg-blue-100 transition-colors"
>+ Condición</button>
<button
onclick={() => store.addLogic(node.id, 'and')}
class="rounded bg-purple-50 px-2 py-1 text-xs text-purple-600 hover:bg-purple-100 transition-colors"
>+ Grupo</button>
{#if depth > 0}
<button
onclick={() => store.remove(node.id)}
class="rounded bg-red-50 px-2 py-1 text-xs text-red-500 hover:bg-red-100 transition-colors"
>✕</button>
{/if}
</div>
</div>
{#if node.children.length === 0}
<p class="text-xs text-gray-400 italic px-1">Sin condiciones — añade una arriba</p>
{:else}
<div class="flex flex-col gap-1.5 pl-3 border-l-2 border-gray-200">
{#each node.children as child (child.id)}
<ConditionNode node={child} {store} depth={depth + 1} />
{/each}
</div>
{/if}
</div>

@ -0,0 +1,62 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const cat = $derived(editor.cat)
const sel = $derived(editor.selection)
const activeObjId = $derived(
sel.kind === 'obj' || sel.kind === 'sec' || sel.kind === 'att' ? sel.objId : null
)
</script>
<!-- Header -->
<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">
Objetos ({cat.objs.length})
</span>
<button
onclick={() => editor.addObj()}
class="w-6 h-6 flex items-center justify-center rounded text-gray-500
hover:text-indigo-400 hover:bg-indigo-500/10 transition-colors text-base"
title="Nuevo objeto"
>+</button>
</div>
<div class="flex-1 overflow-y-auto py-1">
{#if cat.objs.length === 0}
<div class="px-4 py-8 text-center text-gray-600 text-xs">
<p>Sin objetos</p>
<p class="mt-1 text-gray-700">Habitación, cocina, baño...</p>
<button
onclick={() => editor.addObj()}
class="mt-3 text-xs px-3 py-1.5 rounded border border-dashed border-gray-700
text-gray-500 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
>Crear objeto</button>
</div>
{:else}
{#each cat.objs as obj (obj.id)}
{@const isActive = activeObjId === obj.id}
{@const attCount = obj.atts.length + obj.secs.reduce((n, s) => n + s.atts.length, 0)}
<div
role="button" tabindex="0"
onclick={() => editor.select({ kind: 'obj', objId: obj.id })}
onkeydown={e => e.key === 'Enter' && editor.select({ kind: 'obj', objId: obj.id })}
class="w-full text-left 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="flex-1 truncate">{obj.name || 'Sin nombre'}</span>
<span class="text-xs text-gray-600 shrink-0">{attCount}a {obj.secs.length}s</span>
<button
onclick={e => { e.stopPropagation(); editor.removeObj(obj.id) }}
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
transition-opacity text-xs"
>✕</button>
</div>
{/each}
{/if}
</div>

@ -1,118 +0,0 @@
<script lang="ts">
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte.js';
import RuleSetEditor from './RuleSetEditor.svelte';
interface Props { editor: CatalogEditorStore; }
let { editor }: Props = $props();
const sel = $derived(editor.selection);
const obj = $derived(sel.kind === 'obj' ? editor.findObj(sel.objId) ?? null : null);
let nameInput = $state('');
$effect(() => { if (obj) nameInput = obj.name; });
function onNameBlur() {
if (!obj || !sel || sel.kind !== 'obj' || nameInput === obj.name) return;
editor.renameObj(sel.objId, nameInput);
}
function ensureRuleSet() {
if (!obj || sel.kind !== 'obj' || obj.rules) return;
editor.setObjRuleSet(sel.objId, { rules: [] });
}
function removeRuleSet() {
if (sel.kind !== 'obj') return;
editor.setObjRuleSet(sel.objId, undefined);
}
const inputCls = `w-full rounded border border-gray-700 bg-gray-900 px-3 py-2
text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`;
const labelCls = `text-xs font-semibold uppercase tracking-wider text-gray-500`;
</script>
{#if !obj || sel.kind !== 'obj'}
<div class="flex items-center justify-center h-40 text-sm text-gray-600">
Selecciona un objeto
</div>
{:else}
<div class="flex flex-col gap-6 p-5">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-base font-semibold text-gray-100">{obj.name}</h2>
<button
onclick={() => editor.removeObj(sel.objId)}
class="text-xs text-red-500 hover:text-red-400 transition-colors"
>Eliminar objeto</button>
</div>
<!-- Nombre -->
<div class="flex flex-col gap-1.5">
<label class={labelCls}>Nombre</label>
<input type="text" bind:value={nameInput} onblur={onNameBlur} class={inputCls} />
</div>
<!-- Resumen de estructura -->
<div class="flex flex-col gap-1.5">
<span class={labelCls}>Estructura</span>
<div class="rounded border border-gray-800 bg-gray-900 p-3 flex gap-6 text-sm">
<span class="text-gray-400">
<span class="text-gray-100 font-medium">{obj.atts.length}</span> atts directos
</span>
<span class="text-gray-400">
<span class="text-gray-100 font-medium">{obj.secs.length}</span> secciones
</span>
<span class="text-gray-400">
<span class="text-gray-100 font-medium">
{obj.secs.reduce((n, s) => n + s.atts.length, 0)}
</span> atts en secciones
</span>
</div>
</div>
<!-- Orden objs -->
<div class="flex flex-col gap-1.5">
<span class={labelCls}>Posición</span>
<div class="flex gap-2">
<button
onclick={() => editor.moveObj(sel.objId, 'up')}
class="rounded border border-gray-700 px-3 py-1.5 text-sm text-gray-400
hover:text-gray-100 hover:border-gray-500 transition-colors"
>↑ Subir</button>
<button
onclick={() => editor.moveObj(sel.objId, 'down')}
class="rounded border border-gray-700 px-3 py-1.5 text-sm text-gray-400
hover:text-gray-100 hover:border-gray-500 transition-colors"
>↓ Bajar</button>
</div>
</div>
<!-- RuleSet -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class={labelCls}>Reglas</span>
{#if obj.rules}
<button onclick={removeRuleSet}
class="text-xs text-red-500 hover:text-red-400 transition-colors">
✕ Eliminar reglas
</button>
{:else}
<button onclick={ensureRuleSet}
class="text-xs text-blue-400 hover:text-blue-300 transition-colors">
+ Añadir reglas
</button>
{/if}
</div>
{#if obj.rules}
<RuleSetEditor
ruleSet={obj.rules}
container={obj}
catOpts={editor.cat.opts}
{editor}
/>
{/if}
</div>
</div>
{/if}

@ -0,0 +1,47 @@
<script lang="ts">
/**
* OBJS PANEL — tres columnas
* Col 1: ObjList
* Col 2: SecList (si hay obj activo)
* Col 3: AttList + AttDetail (si hay sec o att activo)
*/
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
import ObjList from './ObjList.svelte'
import SecList from './SecList.svelte'
import AttList from './AttList.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const sel = $derived(editor.selection)
</script>
<div class="flex h-full">
<!-- Col 1 — Objetos -->
<div class="w-52 shrink-0 border-r border-gray-800 flex flex-col">
<ObjList />
</div>
<!-- Col 2 — Secciones (si hay obj activo) -->
{#if sel.kind === 'obj' || sel.kind === 'sec' || sel.kind === 'att'}
<div class="w-52 shrink-0 border-r border-gray-800 flex flex-col">
<SecList />
</div>
{/if}
<!-- Col 3 — Atts + detalle -->
<div class="flex-1 overflow-hidden flex flex-col">
{#if sel.kind === 'sec' || sel.kind === 'att'}
<AttList />
{:else if sel.kind === 'obj'}
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
Selecciona una sección o añade atributos directos
</div>
{:else}
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
Selecciona un objeto
</div>
{/if}
</div>
</div>

@ -0,0 +1,144 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const opt = $derived(editor.activeOpt!)
const cat = $derived(editor.cat)
function patch(key: string, value: unknown) {
editor.updateOpt(opt.id, { [key]: value } as any)
}
// Usos — en qué atts está referenciada esta opt
const usages = $derived.by(() => {
const uses: Array<{ objName: string; secName?: string; attName: string }> = []
for (const obj of cat.objs) {
for (const att of obj.atts) {
const d = att.value.domain
if (d.kind === 'option' && d.mode === 'list') {
if ((d as any).list?.some((ao: any) => ao.optId === opt.id)) {
uses.push({ objName: obj.name, attName: att.name })
}
}
}
for (const sec of obj.secs) {
for (const att of sec.atts) {
const d = att.value.domain
if (d.kind === 'option' && d.mode === 'list') {
if ((d as any).list?.some((ao: any) => ao.optId === opt.id)) {
uses.push({ objName: obj.name, secName: sec.name, attName: att.name })
}
}
}
}
}
return uses
})
const valueStr = $derived(String(opt.value.value ?? ''))
const labelStr = $derived(typeof opt.label === 'string' ? opt.label : (opt.label as any)?.es ?? '')
</script>
<div class="p-6 max-w-2xl flex flex-col gap-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-200">
{valueStr || 'Sin valor'}
</h2>
<span class="text-xs text-gray-700 font-mono">{opt.id}</span>
</div>
<!-- Valor e identificación -->
<section class="flex flex-col gap-3">
<h3 class="section-title">Identificación</h3>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">
Valor <span class="text-gray-700">— clave interna</span>
</label>
<input type="text" value={valueStr}
oninput={e => editor.updateOpt(opt.id, { value: { ...opt.value, value: e.currentTarget.value } })}
placeholder="marmol_blanco"
class="field-input font-mono text-xs {!valueStr ? 'invalid' : ''}"/>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">
Etiqueta <span class="text-gray-700">— nombre para el usuario</span>
</label>
<input type="text" value={labelStr}
oninput={e => editor.updateOpt(opt.id, { label: e.currentTarget.value } as any)}
placeholder="Mármol blanco"
class="field-input"/>
</div>
</div>
<!-- Categoría -->
{#if cat.optCategories.length > 0}
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">Categoría</label>
<select
onchange={e => patch('categoryId', e.currentTarget.value || undefined)}
class="field-input"
>
<option value="" selected={!opt.categoryId}>— sin categoría —</option>
{#each cat.optCategories as c (c.id)}
<option value={c.id} selected={opt.categoryId === c.id}>{c.name}</option>
{/each}
</select>
</div>
{/if}
</section>
<!-- Tipo de valor -->
<section class="flex flex-col gap-3">
<h3 class="section-title">Tipo de valor</h3>
<div class="flex gap-2">
{#each (['string', 'number', 'boolean'] as const) as kind}
<button
onclick={() => editor.updateOpt(opt.id, {
value: { domain: { kind, mode: 'input' as any }, value: kind === 'number' ? 0 : kind === 'boolean' ? false : '' }
})}
class="px-3 py-1.5 text-xs rounded border transition-colors</>
{opt.value.kind === kind
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
>{kind}</button>
{/each}
</div>
</section>
<!-- Usos -->
{#if usages.length > 0}
<section class="flex flex-col gap-3">
<h3 class="section-title">
Usada en
<span class="text-gray-600 font-normal normal-case">({usages.length})</span>
</h3>
<div class="flex flex-col gap-1">
{#each usages as use}
<div class="flex items-center gap-2 text-xs px-3 py-2 rounded
bg-gray-900/50 border border-gray-800">
<span class="text-gray-400">{use.objName}</span>
{#if use.secName}
<span class="text-gray-700">›</span>
<span class="text-gray-500">{use.secName}</span>
{/if}
<span class="text-gray-700">›</span>
<span class="text-gray-300">{use.attName}</span>
</div>
{/each}
</div>
</section>
{:else}
<section class="flex flex-col gap-2">
<h3 class="section-title">Usos</h3>
<p class="text-xs text-gray-700 italic">No asignada a ningún atributo todavía</p>
</section>
{/if}
</div>

@ -1,246 +0,0 @@
<script lang="ts">
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte.js';
import type { OptID, OptCatID } from '@/vcen/lib';
interface Props { editor: CatalogEditorStore; }
let { editor }: Props = $props();
const cat = $derived(editor.cat);
// ── Edición inline de opt ─────────────────────────────────────────────────
let editingOptId = $state<OptID | null>(null);
let editingValue = $state('');
let editingLabel = $state('');
let editingCatId = $state<OptCatID | ''>('');
function startEdit(optId: OptID) {
const opt = cat.opts.find(o => o.id === optId);
if (!opt) return;
editingOptId = optId;
editingValue = String(opt.value.value);
editingLabel = typeof opt.label === 'string'
? opt.label
: (opt.label as any)?.['es'] ?? '';
editingCatId = opt.categoryId ?? '';
}
function saveEdit() {
if (!editingOptId) return;
editor.updateOpt(editingOptId, {
value: { kind: 'string', value: editingValue },
label: editingLabel || undefined,
categoryId: (editingCatId as OptCatID) || undefined,
});
editingOptId = null;
}
function cancelEdit() { editingOptId = null; }
// ── Nueva categoría inline ────────────────────────────────────────────────
let newCatName = $state('');
function addCategory() {
if (!newCatName.trim()) return;
editor.addOptCategory(newCatName.trim());
newCatName = '';
}
// ── Edición inline de categoría ───────────────────────────────────────────
let editingCatEditId = $state<OptCatID | null>(null);
let editingCatEditName = $state('');
function startEditCat(catId: OptCatID) {
const oc = cat.optCategories.find(c => c.id === catId);
if (!oc) return;
editingCatEditId = catId;
editingCatEditName = typeof oc.name === 'string'
? oc.name
: (oc.name as any)?.['es'] ?? '';
}
function saveEditCat() {
if (!editingCatEditId || !editingCatEditName.trim()) return;
editor.renameOptCategory(editingCatEditId, editingCatEditName.trim());
editingCatEditId = null;
}
// ── Estilos ───────────────────────────────────────────────────────────────
const inputCls = `w-full rounded border border-gray-700 bg-gray-900 px-3 py-2
text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`;
const labelCls = `text-xs font-semibold uppercase tracking-wider text-gray-500`;
const btnSm = `rounded px-2.5 py-1 text-xs transition-colors`;
</script>
<div class="flex flex-col gap-6 p-5">
<!-- ── Categorías ───────────────────────────────────────────────────── -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class={labelCls}>Categorías</span>
</div>
{#if cat.optCategories.length === 0}
<p class="text-xs text-gray-600 italic">Sin categorías</p>
{:else}
<div class="flex flex-col gap-1">
{#each cat.optCategories as oc (oc.id)}
{#if editingCatEditId === oc.id}
<div class="flex items-center gap-2">
<input
type="text"
bind:value={editingCatEditName}
onkeydown={e => { if (e.key === 'Enter') saveEditCat(); if (e.key === 'Escape') editingCatEditId = null; }}
class="flex-1 rounded border border-blue-600 bg-gray-900 px-2 py-1
text-sm text-gray-100 focus:outline-none"
/>
<button onclick={saveEditCat}
class="{btnSm} bg-blue-600 text-white hover:bg-blue-500">✓</button>
<button onclick={() => editingCatEditId = null}
class="{btnSm} bg-gray-800 text-gray-400 hover:text-gray-200">✕</button>
</div>
{:else}
<div class="flex items-center gap-2 rounded border border-gray-800
bg-gray-900 px-3 py-2 group">
<span class="flex-1 text-sm text-gray-300">
{typeof oc.name === 'string' ? oc.name : (oc.name as any)?.['es'] ?? oc.id}
</span>
<span class="text-xs text-gray-600 mr-2">
{cat.opts.filter(o => o.categoryId === oc.id).length} opts
</span>
<button onclick={() => startEditCat(oc.id)}
class="{btnSm} text-gray-600 hover:text-blue-400 opacity-0 group-hover:opacity-100">
✎
</button>
<button onclick={() => editor.removeOptCategory(oc.id)}
class="{btnSm} text-gray-600 hover:text-red-400 opacity-0 group-hover:opacity-100">
✕
</button>
</div>
{/if}
{/each}
</div>
{/if}
<!-- Añadir categoría -->
<div class="flex gap-2 mt-1">
<input
type="text"
bind:value={newCatName}
placeholder="Nueva categoría..."
onkeydown={e => { if (e.key === 'Enter') addCategory(); }}
class="flex-1 rounded border border-gray-700 bg-gray-900 px-3 py-1.5
text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500
placeholder:text-gray-700"
/>
<button
onclick={addCategory}
disabled={!newCatName.trim()}
class="{btnSm} bg-gray-800 text-gray-300 hover:bg-gray-700
disabled:opacity-30 disabled:cursor-not-allowed px-3 py-1.5"
>+ Categoría</button>
</div>
</div>
<!-- ── Opciones ──────────────────────────────────────────────────────── -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class={labelCls}>Opciones <span class="normal-case text-gray-600 ml-1">({cat.opts.length})</span></span>
<button
onclick={() => editor.addOpt()}
class="{btnSm} bg-blue-600 text-white hover:bg-blue-500 px-3 py-1.5"
>+ Opción</button>
</div>
{#if cat.opts.length === 0}
<p class="text-xs text-gray-600 italic">Sin opciones — pulsa "+ Opción" para crear una</p>
{:else}
<div class="flex flex-col gap-1">
{#each cat.opts as opt (opt.id)}
{#if editingOptId === opt.id}
<!-- Fila edición -->
<div class="flex flex-col gap-2 rounded border border-blue-700
bg-gray-900 p-3">
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Valor</span>
<input type="text" bind:value={editingValue}
class="rounded border border-gray-700 bg-gray-800 px-2 py-1
text-sm text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500" />
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Etiqueta (opcional)</span>
<input type="text" bind:value={editingLabel}
placeholder="igual al valor si vacío"
class="rounded border border-gray-700 bg-gray-800 px-2 py-1
text-sm text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500
placeholder:text-gray-700" />
</div>
</div>
{#if cat.optCategories.length > 0}
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Categoría</span>
<select bind:value={editingCatId}
class="rounded border border-gray-700 bg-gray-800 px-2 py-1
text-sm text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500">
<option value="">— sin categoría —</option>
{#each cat.optCategories as oc (oc.id)}
<option value={oc.id}>
{typeof oc.name === 'string' ? oc.name : (oc.name as any)?.['es'] ?? oc.id}
</option>
{/each}
</select>
</div>
{/if}
<div class="flex gap-2 justify-end">
<button onclick={cancelEdit}
class="{btnSm} bg-gray-800 text-gray-400 hover:text-gray-200">
Cancelar
</button>
<button onclick={saveEdit}
class="{btnSm} bg-blue-600 text-white hover:bg-blue-500">
Guardar
</button>
</div>
</div>
{:else}
<!-- Fila normal -->
<div class="flex items-center gap-2 rounded border border-gray-800
bg-gray-900 px-3 py-2 group hover:border-gray-700 transition-colors">
<span class="font-mono text-sm text-gray-100 min-w-[6rem]">
{String(opt.value.value)}
</span>
{#if opt.label}
<span class="text-sm text-gray-500">
→ {typeof opt.label === 'string' ? opt.label : (opt.label as any)?.['es'] ?? ''}
</span>
{/if}
{#if opt.categoryId}
<span class="ml-auto text-xs px-1.5 py-0.5 rounded bg-gray-800
text-gray-500 border border-gray-700">
{cat.optCategories.find(c => c.id === opt.categoryId)?.name ?? opt.categoryId}
</span>
{:else}
<span class="ml-auto"></span>
{/if}
<button onclick={() => startEdit(opt.id)}
class="{btnSm} text-gray-600 hover:text-blue-400
opacity-0 group-hover:opacity-100 transition-opacity">
✎
</button>
<button onclick={() => editor.removeOpt(opt.id)}
class="{btnSm} text-gray-600 hover:text-red-400
opacity-0 group-hover:opacity-100 transition-opacity">
✕
</button>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>

@ -0,0 +1,165 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte'
import OptDetail from './OptDetail.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const cat = $derived(editor.cat)
const opts = $derived(cat.opts)
let search = $state('')
const filtered = $derived(
opts.filter(o =>
!search ||
String(o.value.value).toLowerCase().includes(search.toLowerCase()) ||
(o.label && String(typeof o.label === 'string' ? o.label : (o.label as any)?.es ?? '').toLowerCase().includes(search.toLowerCase()))
)
)
// Agrupar por categoría
const grouped = $derived.by(() => {
const map = new Map<string, typeof filtered>()
const cats = new Map(cat.optCategories.map(c => [c.id, c.name]))
for (const opt of filtered) {
const key = opt.categoryId ? (cats.get(opt.categoryId) ?? '—') : '—'
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(opt)
}
return map
})
const activeOptId = $derived(editor.activeOptId)
</script>
<div class="flex h-full">
<!-- Lista de opts -->
<div class="w-64 shrink-0 border-r border-gray-800 flex flex-col">
<!-- Header -->
<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">
Opciones ({opts.length})
</span>
<button
onclick={() => editor.addOpt()}
class="w-6 h-6 flex items-center justify-center rounded text-gray-500
hover:text-indigo-400 hover:bg-indigo-500/10 transition-colors"
>+</button>
</div>
<!-- Búsqueda -->
<div class="px-3 py-2 border-b border-gray-800 shrink-0">
<input type="text" bind:value={search} placeholder="Buscar..."
class="w-full bg-gray-800 text-xs text-gray-300 px-2.5 py-1.5 rounded
border border-gray-700 focus:outline-none focus:border-indigo-500" />
</div>
<!-- Categorías -->
{#if cat.optCategories.length === 0}
<div class="px-3 py-1.5 border-b border-gray-800 shrink-0">
<button
onclick={() => editor.addOptCategory('Nueva categoría')}
class="text-xs text-gray-600 hover:text-indigo-400 transition-colors"
>+ Añadir categoría</button>
</div>
{:else}
<div class="px-3 py-1.5 border-b border-gray-800 shrink-0 flex flex-wrap gap-1 items-center">
{#each cat.optCategories as cat_ (cat_.id)}
<span class="flex items-center gap-1 px-2 py-0.5 rounded-full bg-gray-800
border border-gray-700 text-xs text-gray-400 group">
<input
type="text"
value={cat_.name}
oninput={e => editor.renameOptCategory(cat_.id, e.currentTarget.value)}
class="bg-transparent outline-none w-16 text-xs"
/>
<button
onclick={() => editor.removeOptCategory(cat_.id)}
class="text-gray-600 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100"
>✕</button>
</span>
{/each}
<button
onclick={() => editor.addOptCategory('Nueva')}
class="text-xs text-gray-600 hover:text-indigo-400 transition-colors px-1"
>+</button>
</div>
{/if}
<!-- Lista -->
<div class="flex-1 overflow-y-auto py-1">
{#if opts.length === 0}
<div class="px-4 py-8 text-center text-gray-600 text-xs">
<p>Sin opciones</p>
<p class="mt-1 text-gray-700">Mármol, parquet, lacado blanco...</p>
<button
onclick={() => editor.addOpt()}
class="mt-3 text-xs px-3 py-1.5 rounded border border-dashed border-gray-700
text-gray-500 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
>Crear opción</button>
</div>
{:else if filtered.length === 0}
<div class="px-4 py-6 text-center text-xs text-gray-600">Sin resultados</div>
{:else}
{#each [...grouped] as [catName, groupOpts]}
<div class="px-3 py-1.5 text-xs text-gray-600 uppercase tracking-wider
bg-gray-900/50 sticky top-0 z-10">
{catName}
</div>
{#each groupOpts as opt (opt.id)}
{@const isActive = activeOptId === opt.id}
<div
role="button" tabindex="0"
onclick={() => editor.setActiveOpt(opt.id)}
onkeydown={e => e.key === 'Enter' && editor.setActiveOpt(opt.id)}
class="px-3 py-2 flex items-center gap-2 cursor-pointer transition-colors group
{isActive
? 'bg-indigo-600/20 text-indigo-300'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'}"
>
<span class="w-1.5 h-1.5 rounded-full shrink-0
{isActive ? 'bg-indigo-400' : 'bg-transparent'}"></span>
<span class="flex-1 truncate text-sm">
{String(opt.value.value) || 'Sin valor'}
</span>
{#if opt.label}
<span class="text-xs text-gray-600 truncate max-w-[4rem]">
{typeof opt.label === 'string' ? opt.label : (opt.label as any)?.es ?? ''}
</span>
{/if}
<button
onclick={e => { e.stopPropagation(); editor.removeOpt(opt.id) }}
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
transition-opacity text-xs"
>✕</button>
</div>
{/each}
{/each}
{/if}
</div>
</div>
<!-- Detalle -->
<div class="flex-1 overflow-auto">
{#if editor.activeOpt}
<OptDetail />
{:else}
<div class="flex flex-col items-center justify-center h-full gap-3 text-gray-600">
<p class="text-sm">Selecciona o crea una opción</p>
<p class="text-xs text-gray-700 max-w-xs text-center">
Las opciones son los valores posibles de los atributos de tipo Opción.
Se definen aquí y se asignan a cada atributo.
</p>
<button
onclick={() => editor.addOpt()}
class="mt-2 text-xs px-3 py-1.5 rounded border border-dashed border-gray-700
text-gray-500 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
>Crear primera opción</button>
</div>
{/if}
</div>
</div>

@ -0,0 +1,291 @@
<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 '../stores/catalog-editor.svelte'
import type {
RuleSet, Rule,
RuleAction, Obj, Sec, Opt, AttState
} from '@/vcen/lib'
import { createConditionBuilderStore } from '../stores/condition-builder.svelte.ts'
import ConditionBuilder from './ConditionBuilder.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
let {
rule,
ruleSet,
container,
catOpts,
}: {
rule: Rule
ruleSet: RuleSet
container: Obj | Sec
catOpts: Opt[]
} = $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
}
})
function syncCondition() {
if (cbStore.isValid) {
editor.updateRule(ruleSet, rule.id, { condition: cbStore.compiled as any })
}
}
// Todos los atributos del container como targets
const allAttrs = $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}` })
}
for (const sec of obj.secs) {
for (const att of sec.atts) {
attrs.push({ id: att.id, label: `${obj.name} / ${sec.name} / ${att.name || att.id}` })
}
}
}
return 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' },
]
const ACTION_KINDS = [
{ kind: 'attribute', label: 'Estado atributo' },
{ kind: 'validation', label: 'Validación' },
{ kind: 'domain', label: 'Dominio' },
{ kind: 'value', label: 'Valor' },
] as const
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 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: '' } },
}
editor.addAction(ruleSet, rule.id, defaults[kind] ?? defaults.attribute)
}
</script>
<div class="rounded border border-gray-800 bg-gray-900/60 flex flex-col overflow-hidden">
<!-- Header: prioridad + actions count + 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) })}
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)}
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"
>✕</button>
</div>
<div class="flex flex-col gap-0">
<!-- Condición -->
<div class="px-3 py-2.5 border-b border-gray-800">
<ConditionBuilder
store={cbStore}
onchange={syncCondition}
/>
</div>
<!-- Acciones -->
<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>
</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}
</div>
{/each}
</div>
</div>
</div>

@ -0,0 +1,51 @@
<script lang="ts">
/**
* RULE LIST — lista de reglas de un RuleSet
* Sigue exactamente el patrón de RuleList.svelte + RuleEditor.svelte de referencia
*/
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte'
import type { RuleSet , Obj, Sec } from '@/vcen/lib'
import RuleEditor from './RuleEditor.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
let {
ruleSet,
container,
catOpts,
}: {
ruleSet: RuleSet
container: Obj | Sec
catOpts: typeof editor.cat.opts
} = $props()
function addRule() {
editor.addRule(ruleSet)
}
</script>
<div class="flex flex-col gap-2">
{#if ruleSet.rules.length === 0}
<p class="text-xs text-gray-700 italic px-1">Sin reglas</p>
{:else}
{#each ruleSet.rules as rule (rule.id)}
<RuleEditor
{rule}
{ruleSet}
{container}
{catOpts}
{editor}
/>
{/each}
{/if}
<button
onclick={addRule}
class="flex items-center gap-2 px-3 py-2 rounded border border-dashed
border-gray-700 text-xs text-gray-600 hover:text-indigo-400
hover:border-indigo-700 transition-colors"
>+ Añadir regla</button>
</div>

@ -1,317 +0,0 @@
<script lang="ts">
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte';
import type { RuleSet, Rule,
RuleAction, AttState,
RuleActionAttribute,
RuleActionValidation,
RuleActionDomain,
RuleActionValue, Obj, Sec, Opt } from '@/vcen/lib';
import { createConditionBuilderStore } from '../stores/condition-builder.svelte';
import ConditionBuilder from './ConditionBuilder.svelte';
interface Props {
ruleSet: RuleSet;
container: Obj | Sec;
catOpts: Opt[];
editor: CatalogEditorStore;
}
let { ruleSet, container, catOpts, editor }: Props = $props();
// ── Stores de condición por regla (on-demand, estables entre re-renders) ─
const cbStores = new Map<string, ReturnType<typeof createConditionBuilderStore>>();
function getCbStore(rule: Rule) {
if (!cbStores.has(rule.id)) {
cbStores.set(rule.id, createConditionBuilderStore(container, rule.condition, catOpts));
}
return cbStores.get(rule.id)!;
}
function syncCondition(rule: Rule) {
const store = getCbStore(rule);
if (store.isValid) {
editor.updateRule(ruleSet, rule.id, { condition: store.compiled as any });
}
}
// ── Tipos de acción ───────────────────────────────────────────────────────
type ActionKind = RuleAction['type'];
const ACTION_KINDS: { kind: ActionKind; label: string }[] = [
{ kind: 'attribute', label: 'Estado del atributo' },
{ kind: 'validation', label: 'Validación' },
{ kind: 'domain', label: 'Dominio' },
{ kind: 'value', label: 'Valor' },
];
const ATT_STATES: { state: AttState; label: string }[] = [
{ state: 'available', label: 'Disponible' },
{ state: 'required', label: 'Requerido' },
{ state: 'hidden', label: 'Oculto' },
{ state: 'forbidden', label: 'Prohibido' },
];
function defaultAction(kind: ActionKind): RuleAction {
switch (kind) {
case 'attribute': return { type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' };
case 'validation': return { type: 'validation', level: 'error', message: '' };
case 'domain': return { type: 'domain', target: [], state: 'allowed' };
case 'value': return { type: 'value', target: [], value: { domain: { kind: 'string', mode: 'input' }, value: '' } };
}
}
// ── Labels editables por acción (local, no se persisten en el Cat) ────────
// Se guardan en (action as any).label si el usuario escribe algo
function getActionLabel(action: RuleAction, idx: number): string {
return (action as any).label ?? '';
}
function setActionLabel(ruleId: string, idx: number, label: string, rule: Rule) {
const action = rule.actions[idx];
if (!action) return;
editor.updateAction(ruleSet, ruleId, idx, { ...action, label } as any);
}
// ── Reglas colapsadas ────────────────────────────────────────────────────
let collapsedRules = $state<Set<string>>(new Set());
function toggleCollapse(ruleId: string) {
const next = new Set(collapsedRules);
next.has(ruleId) ? next.delete(ruleId) : next.add(ruleId);
collapsedRules = next;
}
</script>
<div class="flex flex-col gap-2">
{#if ruleSet.rules.length === 0}
<p class="text-xs text-gray-600 italic px-1">Sin reglas</p>
{/if}
{#each ruleSet.rules as rule (rule.id)}
{@const cbStore = getCbStore(rule)}
{@const collapsed = collapsedRules.has(rule.id)}
<div class="rounded border border-gray-700 overflow-hidden">
<!-- ── Header de la regla ──────────────────────────────────────── -->
<div class="flex items-center gap-2 px-3 py-2 bg-gray-900 border-b border-gray-700">
<button
onclick={() => toggleCollapse(rule.id)}
class="text-gray-600 hover:text-gray-300 transition-colors text-xs w-4 shrink-0"
>{collapsed ? '▸' : '▾'}</button>
<span class="text-[11px] text-gray-600 shrink-0">P</span>
<input
type="number"
value={rule.priority}
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:ring-1 focus:ring-indigo-500 shrink-0"
/>
<span class="flex-1 text-xs text-gray-500">
{rule.actions.length} acción{rule.actions.length !== 1 ? 'es' : ''}
{#if !cbStore.isValid}
<span class="ml-1 text-amber-600">· condición incompleta</span>
{/if}
</span>
<button
onclick={() => editor.removeRule(ruleSet, rule.id)}
class="text-[11px] text-gray-600 hover:text-red-400 transition-colors shrink-0"
>✕</button>
</div>
{#if !collapsed}
<div class="flex flex-col gap-3 p-3 bg-gray-900/50">
<!-- Condición -->
<div class="flex flex-col gap-1.5">
<span class="section-title-inline">Condición</span>
<div class="rounded border border-gray-700 p-2 bg-gray-900">
<ConditionBuilder
store={cbStore}
onchange={() => syncCondition(rule)}
/>
</div>
</div>
<!-- Acciones -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="section-title-inline">Acciones</span>
<select
onchange={e => {
const kind = e.currentTarget.value as ActionKind;
if (!kind) return;
editor.addAction(ruleSet, rule.id, defaultAction(kind));
e.currentTarget.value = '';
}}
class="field-input"
style="width:auto;font-size:0.75rem;padding:0.25rem 1.75rem 0.25rem 0.5rem"
>
<option value="">+ Añadir acción...</option>
{#each ACTION_KINDS as { kind, label }}
<option value={kind}>{label}</option>
{/each}
</select>
</div>
{#if rule.actions.length === 0}
<p class="text-xs text-gray-700 italic px-1">Sin acciones</p>
{/if}
{#each rule.actions as action, idx (idx)}
<div class="flex flex-col gap-2 rounded border border-gray-800 bg-gray-900 p-2.5">
<!-- Header acción: tipo + nombre editable + eliminar -->
<div class="flex items-center gap-2">
<span class="text-[11px] 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={getActionLabel(action, idx)}
oninput={e => setActionLabel(rule.id, idx, e.currentTarget.value, rule)}
placeholder="Descripción opcional..."
class="flex-1 rounded border border-transparent bg-transparent px-1.5 py-0.5
text-xs text-gray-400 placeholder-gray-700
hover:border-gray-700 focus:border-gray-600 focus:bg-gray-800
focus:outline-none transition-colors"
/>
<button
onclick={() => editor.removeAction(ruleSet, rule.id, idx)}
class="text-[11px] text-gray-600 hover:text-red-400 transition-colors shrink-0"
>✕</button>
</div>
<!-- Campos según tipo -->
{#if action.type === 'attribute'}
{@const a = action as RuleActionAttribute}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Si cumple</span>
<select
value={a.ifTrue}
onchange={e => editor.updateAction(ruleSet, rule.id, idx,
{ ...a, ifTrue: e.currentTarget.value as AttState })}
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-[11px] text-gray-600">Si no cumple</span>
<select
value={a.ifFalse ?? 'available'}
onchange={e => editor.updateAction(ruleSet, rule.id, idx,
{ ...a, ifFalse: e.currentTarget.value as AttState })}
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-[11px] text-gray-600">Target (vacío = self)</span>
<input type="text"
value={(a.target ?? []).join(', ')}
oninput={e => editor.updateAction(ruleSet, rule.id, idx,
{ ...a, target: e.currentTarget.value
.split(',').map((s:string) => s.trim()).filter(Boolean) as any })}
placeholder="vacío = el propio att"
class="field-input" />
</div>
{:else if action.type === 'validation'}
{@const a = action as RuleActionValidation}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Nivel</span>
<select value={a.level}
onchange={e => editor.updateAction(ruleSet, rule.id, idx,
{ ...a, level: e.currentTarget.value as any })}
class="field-input">
<option value="error">Error</option>
<option value="warning">Aviso</option>
</select>
</div>
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Mensaje</span>
<input type="text" value={a.message ?? ''}
oninput={e => editor.updateAction(ruleSet, rule.id, idx,
{ ...a, message: e.currentTarget.value })}
placeholder="Campo obligatorio"
class="field-input" />
</div>
</div>
{:else if action.type === 'domain'}
{@const a = action as RuleActionDomain}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Estado</span>
<select value={a.state}
onchange={e => editor.updateAction(ruleSet, rule.id, idx,
{ ...a, state: e.currentTarget.value as any })}
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-[11px] text-gray-600">Target</span>
<input type="text"
value={(a.target ?? []).join(', ')}
oninput={e => editor.updateAction(ruleSet, rule.id, idx,
{ ...a, target: e.currentTarget.value
.split(',').map((s:string) => s.trim()).filter(Boolean) as any })}
class="field-input" />
</div>
</div>
{:else if action.type === 'value'}
{@const a = action as RuleActionValue}
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Valor (JSON)</span>
<input type="text"
value={JSON.stringify((a.value as any)?.value ?? '')}
oninput={e => {
try {
const parsed = JSON.parse(e.currentTarget.value);
editor.updateAction(ruleSet, rule.id, idx,
{ ...a, value: { ...a.value, value: parsed } as any });
} catch { /* JSON inválido, esperar */ }
}}
placeholder='"texto" o 42 o true'
class="field-input" />
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/each}
<!-- Añadir regla -->
<button
onclick={() => editor.addRule(ruleSet)}
class="rounded border border-dashed border-gray-700 px-3 py-2 text-sm
text-gray-600 hover:text-indigo-400 hover:border-indigo-700 transition-colors w-full mt-1"
>+ Regla</button>
</div>

@ -0,0 +1,141 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const sel = $derived(editor.selection)
const objId = $derived(
sel.kind === 'obj' || sel.kind === 'sec' || sel.kind === 'att' ? sel.objId : null
)
const obj = $derived(objId ? editor.findObj(objId) ?? null : null)
const activeSecId = $derived(
sel.kind === 'sec' || sel.kind === 'att' ? sel.secId ?? null : null
)
// Nombre inline editable del obj
let editingObjName = $state(false)
let objNameDraft = $state('')
function startEditObj() {
if (!obj) return
objNameDraft = obj.name
editingObjName = true
}
function saveObjName() {
if (obj && objId && objNameDraft.trim()) editor.renameObj(objId, objNameDraft.trim())
editingObjName = false
}
</script>
{#if obj && objId}
<!-- Header: nombre obj editable + botón add sec -->
<div class="px-4 py-3 border-b border-gray-800 shrink-0">
{#if editingObjName}
<input
type="text"
bind:value={objNameDraft}
onblur={saveObjName}
onkeydown={e => { if (e.key === 'Enter') saveObjName(); if (e.key === 'Escape') editingObjName = false; }}
class="field-input text-xs"
autofocus
/>
{:else}
<button
ondblclick={startEditObj}
class="text-xs font-semibold text-gray-300 uppercase tracking-wider
hover:text-gray-100 transition-colors w-full text-left"
title="Doble clic para renombrar"
>
{obj.name || 'Sin nombre'}
</button>
{/if}
</div>
<!-- Atts directos del obj -->
<div class="px-4 py-2 border-b border-gray-800 shrink-0 flex items-center justify-between">
<span class="text-xs font-semibold text-amber-600 uppercase tracking-wider">
Atts directos <span class="text-gray-700 font-normal">({obj.atts.length})</span>
</span>
<button
onclick={() => editor.addAtt(objId, undefined)}
class="w-5 h-5 flex items-center justify-center rounded text-gray-600
hover:text-amber-400 hover:bg-amber-500/10 transition-colors"
>+</button>
</div>
{#each obj.atts as att (att.id)}
{@const isActive = sel.kind === 'att' && sel.attId === att.id && !sel.secId}
<div
role="button" tabindex="0"
onclick={() => editor.select({ kind: 'att', objId, attId: att.id })}
onkeydown={e => e.key === 'Enter' && editor.select({ kind: 'att', objId, attId: att.id })}
class="px-4 py-2 flex items-center gap-2 text-sm cursor-pointer transition-colors group border-l-2
{isActive
? 'bg-amber-600/20 text-amber-300 border-amber-500'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}"
>
<span class="text-xs px-1 py-0.5 rounded bg-gray-800 text-gray-500 shrink-0 font-mono">
{att.value.domain.kind.slice(0, 3)}
</span>
<span class="flex-1 truncate">{att.name || 'Sin nombre'}</span>
<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}
<!-- Secciones -->
<div class="px-4 py-2 border-t border-b border-gray-800 flex items-center justify-between
sticky top-0 bg-gray-950 z-10 mt-1">
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
Secciones <span class="text-gray-700 font-normal">({obj.secs.length})</span>
</span>
<button
onclick={() => editor.addSec(objId)}
class="w-5 h-5 flex items-center justify-center rounded text-gray-600
hover:text-indigo-400 hover:bg-indigo-500/10 transition-colors"
>+</button>
</div>
<div class="flex-1 overflow-y-auto py-1">
{#if obj.secs.length === 0}
<div class="px-4 py-5 text-center text-gray-700 text-xs italic">
Sin secciones
<button
onclick={() => editor.addSec(objId)}
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"
>Crear sección</button>
</div>
{:else}
{#each obj.secs as sec (sec.id)}
{@const isActive = activeSecId === sec.id}
<div
role="button" tabindex="0"
onclick={() => editor.select({ kind: 'sec', objId, secId: sec.id })}
onkeydown={e => e.key === 'Enter' && editor.select({ kind: 'sec', objId, secId: sec.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 text-gray-600 shrink-0">▤</span>
<span class="flex-1 truncate">{sec.name || 'Sin nombre'}</span>
<span class="text-xs text-gray-600 shrink-0">{sec.atts.length}</span>
<button
onclick={e => { e.stopPropagation(); editor.removeSec(objId, sec.id) }}
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
transition-opacity text-xs"
>✕</button>
</div>
{/each}
{/if}
</div>
{/if}

@ -1,118 +0,0 @@
<script lang="ts">
import type { CatalogEditorStore } from './catalog-editor.svelte';
import RuleSetEditor from './RuleSetEditor.svelte';
interface Props { editor: CatalogEditorStore; }
let { editor }: Props = $props();
const sel = $derived(editor.selection);
const sec = $derived(
sel.kind === 'sec' ? editor.findSec(sel.objId, sel.secId) ?? null : null
);
const obj = $derived(
sel.kind === 'sec' ? editor.findObj(sel.objId) ?? null : null
);
let nameInput = $state('');
$effect(() => { if (sec) nameInput = sec.name; });
function onNameBlur() {
if (!sec || sel.kind !== 'sec' || nameInput === sec.name) return;
editor.renameSec(sel.objId, sel.secId, nameInput);
}
function ensureRuleSet() {
if (!sec || sel.kind !== 'sec' || sec.rules) return;
editor.setSecRuleSet(sel.objId, sel.secId, { rules: [] });
}
function removeRuleSet() {
if (sel.kind !== 'sec') return;
editor.setSecRuleSet(sel.objId, sel.secId, undefined);
}
const inputCls = `w-full rounded border border-gray-700 bg-gray-900 px-3 py-2
text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`;
const labelCls = `text-xs font-semibold uppercase tracking-wider text-gray-500`;
</script>
{#if !sec || !obj || sel.kind !== 'sec'}
<div class="flex items-center justify-center h-40 text-sm text-gray-600">
Selecciona una sección
</div>
{:else}
<div class="flex flex-col gap-6 p-5">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<span class="text-xs text-gray-600">{obj.name}</span>
<h2 class="text-base font-semibold text-gray-100">{sec.name}</h2>
</div>
<button
onclick={() => editor.removeSec(sel.objId, sel.secId)}
class="text-xs text-red-500 hover:text-red-400 transition-colors"
>Eliminar sección</button>
</div>
<!-- Nombre -->
<div class="flex flex-col gap-1.5">
<label class={labelCls}>Nombre</label>
<input type="text" bind:value={nameInput} onblur={onNameBlur} class={inputCls} />
</div>
<!-- Resumen -->
<div class="flex flex-col gap-1.5">
<span class={labelCls}>Estructura</span>
<div class="rounded border border-gray-800 bg-gray-900 p-3 text-sm">
<span class="text-gray-400">
<span class="text-gray-100 font-medium">{sec.atts.length}</span> atributos
</span>
</div>
</div>
<!-- Orden -->
<div class="flex flex-col gap-1.5">
<span class={labelCls}>Posición en "{obj.name}"</span>
<div class="flex gap-2">
<button
onclick={() => editor.moveSec(sel.objId, sel.secId, 'up')}
class="rounded border border-gray-700 px-3 py-1.5 text-sm text-gray-400
hover:text-gray-100 hover:border-gray-500 transition-colors"
>↑ Subir</button>
<button
onclick={() => editor.moveSec(sel.objId, sel.secId, 'down')}
class="rounded border border-gray-700 px-3 py-1.5 text-sm text-gray-400
hover:text-gray-100 hover:border-gray-500 transition-colors"
>↓ Bajar</button>
</div>
</div>
<!-- RuleSet -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class={labelCls}>Reglas</span>
{#if sec.rules}
<button onclick={removeRuleSet}
class="text-xs text-red-500 hover:text-red-400 transition-colors">
✕ Eliminar reglas
</button>
{:else}
<button onclick={ensureRuleSet}
class="text-xs text-blue-400 hover:text-blue-300 transition-colors">
+ Añadir reglas
</button>
{/if}
</div>
{#if sec.rules}
<RuleSetEditor
ruleSet={sec.rules}
container={sec}
catOpts={editor.cat.opts}
{editor}
/>
{/if}
</div>
</div>
{/if}

@ -0,0 +1,532 @@
import type { Cat, Obj, Sec, Att, AttDomain, AttValue,
Opt, OptCategory, AttOpt,
OptID, OptCatID, RuleSet, Rule, RuleAction,
RuleID, AttState, RelativePath,
DomainValueRef, DomainValueState,
RuleLevel, CatID, ObjID, SecID, AttID } from '@/vcen/lib';
// ─────────────────────────────────────────────────────────────────────────────
// SELECTION — nodo activo en el árbol
// ─────────────────────────────────────────────────────────────────────────────
export type SelectionKind = 'cat' | 'obj' | 'sec' | 'att' | 'opts';
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 };
// ─────────────────────────────────────────────────────────────────────────────
// ID GENERATORS
// ─────────────────────────────────────────────────────────────────────────────
let _seq = Date.now();
function nextId<T extends string>(prefix: string): T {
return `${prefix}:${(++_seq).toString(36)}` as T;
}
// ─────────────────────────────────────────────────────────────────────────────
// DEFAULT FACTORIES
// ─────────────────────────────────────────────────────────────────────────────
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 defaultSec(name = 'Nueva sección'): Sec {
return { id: nextId<SecID>('sc'), name, atts: [] };
}
function defaultObj(name = 'Nuevo objeto'): Obj {
return { id: nextId<ObjID>('ob'), name, atts: [], secs: [] };
}
function defaultOpt(value = ''): Opt {
return {
id: nextId<OptID>('op'),
value: { kind: 'string', value },
};
}
function defaultOptCategory(name = 'Nueva categoría'): OptCategory {
return { id: nextId<OptCatID>('oc'), name };
}
function defaultRule(): Rule {
return {
id: nextId<RuleID>('rl'),
priority: 10,
condition: true,
actions: [],
};
}
// ─────────────────────────────────────────────────────────────────────────────
// STORE
// ─────────────────────────────────────────────────────────────────────────────
/**
* Store Svelte 5 para el editor de catálogo.
*
* Gestiona el Cat en modo borrador con mutaciones granulares.
* La UI lee `cat` directamente (reactivo) y llama a las mutaciones
* para cada operación de edición.
*
* ## Uso
*
* ```svelte
* <script>
* import { createCatalogEditorStore } from './catalog-editor.svelte';
* const editor = createCatalogEditorStore(cat);
* </script>
*
* <CatalogTree {editor} />
* ```
*/
export function createCatalogEditorStore(initial: Cat) {
let cat = $state<Cat>(structuredClone(initial));
let selection = $state<Selection>({ kind: 'cat' });
let dirty = $state(false);
let panel = $state<'cat' | 'objs' | 'opts'>('objs');
// ── Helpers internos ────────────────────────────────────────────────────
function mutate(fn: () => void) {
fn();
dirty = true;
}
function findObj(objId: ObjID): Obj | undefined {
return cat.objs.find(o => o.id === objId);
}
function findSec(objId: ObjID, secId: SecID): Sec | undefined {
return findObj(objId)?.secs.find(s => s.id === secId);
}
function findAtt(objId: ObjID, secId: SecID | undefined, attId: AttID): Att | undefined {
if (secId) return findSec(objId, secId)?.atts.find(a => a.id === attId);
return findObj(objId)?.atts.find(a => a.id === attId);
}
function findCatAtt(attId: AttID): Att | undefined {
return cat.atts.find(a => a.id === attId);
}
// ── Selección ───────────────────────────────────────────────────────────
function select(s: Selection) {
selection = s;
}
// ── Cat ─────────────────────────────────────────────────────────────────
function renameCat(name: string) {
mutate(() => { cat.name = name; });
}
// ── Obj ─────────────────────────────────────────────────────────────────
function addObj(name?: string): ObjID {
const obj = defaultObj(name);
mutate(() => { cat.objs.push(obj); });
select({ kind: 'obj', objId: obj.id });
return obj.id;
}
function renameObj(objId: ObjID, name: string) {
mutate(() => {
const obj = findObj(objId);
if (obj) obj.name = name;
});
}
function removeObj(objId: ObjID) {
mutate(() => {
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' });
}
function moveObj(objId: ObjID, direction: 'up' | 'down') {
mutate(() => {
const idx = cat.objs.findIndex(o => o.id === objId);
if (idx === -1) return;
const target = direction === 'up' ? idx - 1 : idx + 1;
if (target < 0 || target >= cat.objs.length) return;
[cat.objs[idx], cat.objs[target]] = [cat.objs[target], cat.objs[idx]];
});
}
// ── Sec ─────────────────────────────────────────────────────────────────
function addSec(objId: ObjID, name?: string): SecID {
const sec = defaultSec(name);
mutate(() => {
const obj = findObj(objId);
if (obj) obj.secs.push(sec);
});
select({ kind: 'sec', objId, secId: sec.id });
return sec.id;
}
function renameSec(objId: ObjID, secId: SecID, name: string) {
mutate(() => {
const sec = findSec(objId, secId);
if (sec) sec.name = name;
});
}
function removeSec(objId: ObjID, secId: SecID) {
mutate(() => {
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 });
}
function moveSec(objId: ObjID, secId: SecID, direction: 'up' | 'down') {
mutate(() => {
const obj = findObj(objId);
if (!obj) return;
const idx = obj.secs.findIndex(s => s.id === secId);
if (idx === -1) return;
const target = direction === 'up' ? idx - 1 : idx + 1;
if (target < 0 || target >= obj.secs.length) return;
[obj.secs[idx], obj.secs[target]] = [obj.secs[target], obj.secs[idx]];
});
}
function setSecRuleSet(objId: ObjID, secId: SecID, ruleSet: RuleSet | undefined) {
mutate(() => {
const sec = findSec(objId, secId);
if (sec) sec.rules = ruleSet;
});
}
// ── Att (en Obj o Sec) ──────────────────────────────────────────────────
function addAtt(objId: ObjID, secId: SecID | undefined, name?: string): AttID {
const att = defaultAtt(name);
mutate(() => {
if (secId) {
const sec = findSec(objId, secId);
if (sec) sec.atts.push(att);
} else {
const obj = findObj(objId);
if (obj) obj.atts.push(att);
}
});
select({ kind: 'att', objId, secId, attId: att.id });
return att.id;
}
function addCatAtt(name?: string): AttID {
const att = defaultAtt(name);
mutate(() => { cat.atts.push(att); });
select({ kind: 'att', objId: '' as ObjID, secId: undefined, attId: att.id });
return att.id;
}
function renameAtt(objId: ObjID, secId: SecID | undefined, attId: AttID, name: string) {
mutate(() => {
const att = findAtt(objId, secId, attId);
if (att) att.name = name;
});
}
function removeAtt(objId: ObjID, secId: SecID | undefined, attId: AttID) {
mutate(() => {
if (secId) {
const sec = findSec(objId, secId);
if (sec) sec.atts = sec.atts.filter(a => a.id !== attId);
} else {
const obj = findObj(objId);
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 });
}
}
function moveAtt(objId: ObjID, secId: SecID | undefined, attId: AttID, direction: 'up' | 'down') {
mutate(() => {
const list = secId ? findSec(objId, secId)?.atts : findObj(objId)?.atts;
if (!list) return;
const idx = list.findIndex(a => a.id === attId);
if (idx === -1) return;
const target = direction === 'up' ? idx - 1 : idx + 1;
if (target < 0 || target >= list.length) return;
[list[idx], list[target]] = [list[target], list[idx]];
});
}
function updateAttDomain(objId: ObjID, secId: SecID | undefined, attId: AttID, domain: AttDomain) {
mutate(() => {
const att = findAtt(objId, secId, attId);
if (!att) return;
// Cambiar dominio resetea el valor al default del nuevo kind
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);
if (att) (att.value as any).value = value;
});
}
function setAttRuleSet(objId: ObjID, secId: SecID | undefined, attId: AttID, ruleSet: RuleSet | undefined) {
mutate(() => {
const att = findAtt(objId, secId, attId);
if (att) att.rules = ruleSet;
});
}
function setObjRuleSet(objId: ObjID, ruleSet: RuleSet | undefined) {
mutate(() => {
const obj = findObj(objId);
if (obj) obj.rules = ruleSet;
});
}
// ── Opts globales ───────────────────────────────────────────────────────
function addOpt(value = '', categoryId?: OptCatID): OptID {
const opt = defaultOpt(value);
if (categoryId) opt.categoryId = categoryId;
mutate(() => { cat.opts.push(opt); });
return opt.id;
}
function updateOpt(optId: OptID, patch: Partial<Pick<Opt, 'value' | 'label' | 'categoryId' | 'rules'>>) {
mutate(() => {
const opt = cat.opts.find(o => o.id === optId);
if (!opt) return;
if (patch.value !== undefined) opt.value = patch.value;
if (patch.label !== undefined) opt.label = patch.label;
if (patch.categoryId !== undefined) opt.categoryId = patch.categoryId;
if (patch.rules !== undefined) opt.rules = patch.rules;
});
}
function removeOpt(optId: OptID) {
mutate(() => {
cat.opts = cat.opts.filter(o => o.id !== optId);
// Limpiar referencias en dominios option/list de todos los atts
removeOptFromAllDomains(optId);
});
}
function removeOptFromAllDomains(optId: OptID) {
// Cat-level atts
for (const att of cat.atts) pruneAttOpt(att, optId);
// Obj/Sec atts
for (const obj of cat.objs) {
for (const att of obj.atts) pruneAttOpt(att, optId);
for (const sec of obj.secs) {
for (const att of sec.atts) pruneAttOpt(att, optId);
}
}
}
function pruneAttOpt(att: Att, optId: OptID) {
if (att.value.domain.kind === 'option' && att.value.domain.mode === 'list') {
(att.value.domain as any).list = (att.value.domain as any).list.filter(
(ao: AttOpt) => ao.optId !== optId
);
}
}
function addAttOpt(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID) {
mutate(() => {
const att = findAtt(objId, secId, attId);
if (!att || att.value.domain.kind !== 'option' || att.value.domain.mode !== 'list') return;
const list = (att.value.domain as any).list as AttOpt[];
if (!list.find(ao => ao.optId === optId)) {
list.push({ optId });
}
});
}
function removeAttOpt(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID) {
mutate(() => {
const att = findAtt(objId, secId, attId);
if (!att || att.value.domain.kind !== 'option' || att.value.domain.mode !== 'list') return;
(att.value.domain as any).list = ((att.value.domain as any).list as AttOpt[])
.filter(ao => ao.optId !== optId);
});
}
// ── OptCategories ───────────────────────────────────────────────────────
function addOptCategory(name?: string): OptCatID {
const oc = defaultOptCategory(name);
mutate(() => { cat.optCategories.push(oc); });
return oc.id;
}
function renameOptCategory(ocId: OptCatID, name: string) {
mutate(() => {
const oc = cat.optCategories.find(c => c.id === ocId);
if (oc) oc.name = name;
});
}
function removeOptCategory(ocId: OptCatID) {
mutate(() => {
cat.optCategories = cat.optCategories.filter(c => c.id !== ocId);
// Opts que apuntaban a esta categoría quedan sin categoría
for (const opt of cat.opts) {
if (opt.categoryId === ocId) delete opt.categoryId;
}
});
}
// ── RuleSet helpers ─────────────────────────────────────────────────────
function addRule(ruleSet: RuleSet): RuleID {
const rule = defaultRule();
mutate(() => { ruleSet.rules.push(rule); });
return rule.id;
}
function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial<Pick<Rule, 'condition' | 'priority'>>) {
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;
});
}
function removeRule(ruleSet: RuleSet, ruleId: RuleID) {
mutate(() => {
ruleSet.rules = ruleSet.rules.filter(r => r.id !== ruleId);
});
}
function addAction(ruleSet: RuleSet, ruleId: RuleID, action: RuleAction) {
mutate(() => {
const rule = ruleSet.rules.find(r => r.id === ruleId);
if (rule) rule.actions.push(action);
});
}
function updateAction(ruleSet: RuleSet, ruleId: RuleID, actionIdx: number, action: RuleAction) {
mutate(() => {
const rule = ruleSet.rules.find(r => r.id === ruleId);
if (rule && actionIdx < rule.actions.length) rule.actions[actionIdx] = action;
});
}
function removeAction(ruleSet: RuleSet, ruleId: RuleID, actionIdx: number) {
mutate(() => {
const rule = ruleSet.rules.find(r => r.id === ruleId);
if (rule) rule.actions.splice(actionIdx, 1);
});
}
// ── Persistencia ────────────────────────────────────────────────────────
/** Devuelve el Cat en estado actual. Útil para guardar. */
function getCat(): Cat {
return cat;
}
/** Marca el store como limpio (p.ej. tras guardar). */
function markClean() {
dirty = false;
}
/** Descarta todos los cambios y vuelve al Cat inicial. */
function discard() {
cat = structuredClone(initial);
dirty = false;
selection = { kind: 'cat' };
}
// ── Opt activa (para OptsPanel) ──────────────────────────────────────────
let activeOptId = $state<string | null>(null);
// ── API pública ──────────────────────────────────────────────────────────
return {
// Estado reactivo
get cat() { return cat; },
get selection() { return selection; },
get dirty() { return dirty; },
get activeOptId() { return activeOptId; },
get activeOpt() { return activeOptId ? cat.opts.find(o => o.id === activeOptId) ?? null : null; },
setActiveOpt(id: string) { activeOptId = id; },
// Selección
select,
// Cat
renameCat,
// Obj
addObj, renameObj, removeObj, moveObj,
setObjRuleSet,
// Sec
addSec, renameSec, removeSec, moveSec,
setSecRuleSet,
// Att
addAtt, addCatAtt, renameAtt, removeAtt, moveAtt,
updateAttDomain, updateAttDefaultValue, setAttRuleSet,
// Opts globales
addOpt, updateOpt, removeOpt, addAttOpt, removeAttOpt,
// OptCategories
addOptCategory, renameOptCategory, removeOptCategory,
// RuleSet helpers (operan sobre cualquier RuleSet pasado por ref)
addRule, updateRule, removeRule,
addAction, updateAction, removeAction,
// 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,
};
}
export type CatalogEditorStore = ReturnType<typeof createCatalogEditorStore>;
// ─────────────────────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────────────────────
function defaultValueForDomain(domain: AttDomain): unknown {
switch (domain.kind) {
case 'string': return '';
case 'number': return 0;
case 'currency': return 0;
case 'boolean': return false;
case 'option': return null;
case 'date': return '';
case 'time': return '';
case 'datetime': return '';
}
}

@ -0,0 +1,431 @@
<script lang="ts">
/**
* CONDITION BUILDER
*
* Editor visual para condiciones JsonLogic en el contexto VICE.
* Soporta:
* - Cláusulas simples: attr op value
* - Cláusulas compuestas: AND / OR de N cláusulas
* - Fallback raw (textarea) si el JsonLogic no es parseable
*
* Props:
* condition — JsonLogic actual (undefined = siempre activa)
* allAttrs — attrs disponibles como variables { id, label }
* onchange — callback con el nuevo JsonLogic
*/
import { getContext } from 'svelte'
import type { EditorStore } from '../stores/editor.svelte'
import type { JsonLogic } from '@/tsat/types'
import type { OptionDefinition, OptionID } from '@/vice/libs/types'
const editor = getContext<EditorStore>('editor')
let {
condition,
allAttrs,
onchange,
}: {
condition: JsonLogic | undefined
allAttrs: Array<{ id: string; label: string }>
onchange: (jl: JsonLogic | undefined) => void
} = $props()
// ── Types ────────────────────────────────────────────────────────────────────
type ClauseOp = '==' | '!=' | 'in' | '!in' | '>' | '>=' | '<' | '<='
type Clause = {
attrId: string
op: ClauseOp
optValues: string[] // OptionIDs (== usa [0], in usa todos)
numValue: number
strValue: string
boolValue: boolean
}
type ConditionDraft = {
logic: 'and' | 'or'
clauses: Clause[]
}
type AttrKind = 'options' | 'number' | 'boolean' | 'string' | 'unknown'
// ── Attr meta ────────────────────────────────────────────────────────────────
function getAllAttrsFlat() {
const entity = editor.activeEntity
return [
...editor.collection.attributes,
...(entity ? entity.attributes : []),
...(entity ? Object.values(entity.groups).flatMap(g => g.attributes) : []),
]
}
function getAttrKind(attrId: string): AttrKind {
if (!attrId) return 'unknown'
const attr = getAllAttrsFlat().find(a => a.id === attrId)
if (!attr) return 'unknown'
if (attr.type === 'required' || attr.type === 'optional') return 'options'
if (attr.type === 'literal') {
if (attr.kind === 'number' || attr.kind === 'currency') return 'number'
if (attr.kind === 'boolean') return 'boolean'
return 'string'
}
return 'unknown'
}
function getAttrOptions(attrId: string): OptionDefinition[] {
if (!attrId) return []
const attr = getAllAttrsFlat().find(a => a.id === attrId)
if (!attr || (attr.type !== 'required' && attr.type !== 'optional')) return []
return attr.options
.map(ao => editor.collection.options[ao.optionId as OptionID])
.filter((o): o is OptionDefinition => Boolean(o))
}
const OPS: Record<AttrKind, Array<{ op: ClauseOp; label: string }>> = {
options: [
{ op: '==', label: 'es' },
{ op: '!=', label: 'no es' },
{ op: 'in', label: 'es alguno de' },
{ op: '!in', label: 'no es ninguno de' },
],
number: [
{ op: '==', label: '=' },
{ op: '!=', label: '≠' },
{ op: '>', label: '>' },
{ op: '>=', label: '≥' },
{ op: '<', label: '<' },
{ op: '<=', label: '≤' },
],
string: [{ op: '==', label: 'es' }, { op: '!=', label: 'no es' }],
boolean: [],
unknown: [],
}
// ── JsonLogic ↔ Draft ────────────────────────────────────────────────────────
function emptyClause(): Clause {
return { attrId: '', op: '==', optValues: [], numValue: 0, strValue: '', boolValue: true }
}
function jlToClause(jl: JsonLogic): Clause | null {
if (typeof jl !== 'object' || jl === null || 'var' in jl) return null
const obj = jl as Record<string, unknown>
const key = Object.keys(obj)[0]
// { "!": { "in": [{"var": "..."}, [...]] } }
if (key === '!') {
const inner = obj['!'] as Record<string, unknown>
if (typeof inner === 'object' && inner !== null && 'in' in inner) {
const parts = inner['in'] as unknown[]
if (!Array.isArray(parts) || parts.length !== 2) return null
const [left, right] = parts
if (typeof left !== 'object' || left === null || !('var' in (left as object))) return null
const opts = Array.isArray(right) ? (right as string[]) : [right as string]
return { attrId: (left as any).var, op: '!in', optValues: opts, numValue: 0, strValue: '', boolValue: true }
}
return null
}
const parts = obj[key]
if (!Array.isArray(parts) || parts.length !== 2) return null
const [left, right] = parts as [JsonLogic, JsonLogic]
if (typeof left !== 'object' || left === null || !('var' in (left as object))) return null
const attrId = (left as any).var as string
if (key === 'in') {
const opts = Array.isArray(right) ? (right as string[]) : [right as string]
return { attrId, op: 'in', optValues: opts, numValue: 0, strValue: '', boolValue: true }
}
if (['==', '!=', '>', '>=', '<', '<='].includes(key)) {
const op = key as ClauseOp
if (typeof right === 'number') return { attrId, op, optValues: [], numValue: right, strValue: '', boolValue: true }
if (typeof right === 'boolean') return { attrId, op: '==', optValues: [], numValue: 0, strValue: '', boolValue: right }
if (typeof right === 'string') return { attrId, op, optValues: [right], numValue: 0, strValue: right, boolValue: true }
}
return null
}
function jlToDraft(jl: JsonLogic | undefined): ConditionDraft | null {
if (!jl) return { logic: 'and', clauses: [] }
if (typeof jl !== 'object' || jl === null) return null
const obj = jl as Record<string, unknown>
if ('and' in obj || 'or' in obj) {
const logic: 'and' | 'or' = 'and' in obj ? 'and' : 'or'
const children = obj[logic] as JsonLogic[]
if (!Array.isArray(children)) return null
const clauses = children.map(jlToClause)
if (clauses.some(c => c === null)) return null
return { logic, clauses: clauses as Clause[] }
}
const clause = jlToClause(jl)
return clause ? { logic: 'and', clauses: [clause] } : null
}
function clauseToJl(c: Clause): JsonLogic {
const left: JsonLogic = { var: c.attrId }
const kind = getAttrKind(c.attrId)
if (kind === 'boolean') return { '==': [left, c.boolValue] }
if (kind === 'number') return { [c.op]: [left, c.numValue] }
if (kind === 'string') return { [c.op]: [left, c.strValue] }
// options (default)
if (c.op === 'in') return { in: [left, c.optValues] }
if (c.op === '!in') return { '!': { in: [left, c.optValues] } }
return { [c.op]: [left, c.optValues[0] ?? ''] }
}
function draftToJl(d: ConditionDraft): JsonLogic | undefined {
const valid = d.clauses.filter(c => c.attrId !== '')
if (valid.length === 0) return undefined
const exprs = valid.map(clauseToJl)
if (exprs.length === 1) return exprs[0]
return { [d.logic]: exprs }
}
// ── State ────────────────────────────────────────────────────────────────────
const initialDraft = jlToDraft(condition)
let parseFailed = $state(initialDraft === null)
let rawMode = $state(initialDraft === null)
let draft = $state<ConditionDraft>(initialDraft ?? { logic: 'and', clauses: [] })
// ── Mutations ────────────────────────────────────────────────────────────────
function emit() {
onchange(draftToJl(draft))
}
function setLogic(logic: 'and' | 'or') {
draft = { ...draft, logic }
emit()
}
function addClause() {
draft = { ...draft, clauses: [...draft.clauses, emptyClause()] }
emit()
}
function removeClause(i: number) {
draft = { ...draft, clauses: draft.clauses.filter((_, idx) => idx !== i) }
emit()
}
function patchClause(i: number, patch: Partial<Clause>) {
draft = { ...draft, clauses: draft.clauses.map((c, idx) => idx === i ? { ...c, ...patch } : c) }
emit()
}
function setAttr(i: number, attrId: string) {
const kind = getAttrKind(attrId)
const op = OPS[kind][0]?.op ?? '=='
patchClause(i, { attrId, op, optValues: [], numValue: 0, strValue: '', boolValue: true })
}
function toggleOpt(i: number, optId: string, multi: boolean) {
const current = draft.clauses[i]?.optValues ?? []
const next = multi
? (current.includes(optId) ? current.filter(v => v !== optId) : [...current, optId])
: [optId]
patchClause(i, { optValues: next })
}
// ── Helpers ──────────────────────────────────────────────────────────────────
const langs = $derived(editor.collection.langs)
function optLabel(opt: OptionDefinition): string {
const n = opt.name as Record<string, string> | string
if (typeof n === 'string') return n || opt.code
return n[langs[0]] ?? n['es'] ?? opt.code
}
const activeClauseCount = $derived(draft.clauses.filter(c => c.attrId).length)
</script>
<div class="flex flex-col gap-2">
<!-- Header con toggle raw/visual -->
<div class="flex items-center justify-between min-h-5">
<span class="text-xs text-gray-600 uppercase tracking-wider">Condición</span>
<div class="flex items-center gap-2">
{#if !rawMode && activeClauseCount === 0}
<span class="text-xs text-gray-700 italic">siempre activa</span>
{/if}
{#if parseFailed}
<span class="text-xs text-amber-700 italic">JsonLogic complejo</span>
{:else}
<button
onclick={() => rawMode = !rawMode}
class="text-xs text-gray-600 hover:text-gray-400 transition-colors underline underline-offset-2"
>{rawMode ? 'visual' : 'raw'}</button>
{/if}
</div>
</div>
{#if rawMode || parseFailed}
<!-- ── Raw textarea ────────────────────────────────────────────── -->
<textarea
rows="3"
placeholder={'{\n "==": [{"var": "at:suelo"}, "op:marmol"]\n}'}
value={condition ? JSON.stringify(condition, null, 2) : ''}
oninput={(e) => {
const v = e.currentTarget.value.trim()
try {
const parsed = v ? JSON.parse(v) : undefined
const d = jlToDraft(parsed)
if (d) { draft = d; parseFailed = false }
onchange(parsed)
} catch { /* JSON inválido — esperar */ }
}}
class="field-input font-mono text-xs resize-none"
spellcheck={false}
></textarea>
<p class="text-xs text-gray-700">
JsonLogic — variables son AttrIDs. Vacío = siempre activa.
</p>
{:else}
<!-- ── Visual builder ──────────────────────────────────────────── -->
<!-- AND / OR (solo con 2+ cláusulas) -->
{#if draft.clauses.length > 1}
<div class="flex items-center gap-1.5 text-xs">
<span class="text-gray-600 shrink-0">Combinar con</span>
{#each (['and', 'or'] as const) as l}
<button
onclick={() => setLogic(l)}
class="px-2 py-0.5 rounded border text-xs transition-colors
{draft.logic === l
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600'}"
>{l.toUpperCase()}</button>
{/each}
</div>
{/if}
<!-- Cláusulas -->
{#each draft.clauses as clause, i (i)}
{@const kind = getAttrKind(clause.attrId)}
{@const ops = OPS[kind]}
{@const options = getAttrOptions(clause.attrId)}
{@const multi = clause.op === 'in' || clause.op === '!in'}
<div class="flex flex-col gap-1.5 rounded border border-gray-800 bg-gray-900/40 p-2">
<!-- Fila de controles -->
<div class="flex items-center gap-1.5 flex-wrap">
<!-- Atributo -->
<select
value={clause.attrId}
onchange={(e) => setAttr(i, e.currentTarget.value)}
class="flex-1 min-w-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1"
>
<option value="">— atributo —</option>
{#each allAttrs as a}
<option value={a.id}>{a.label}</option>
{/each}
</select>
<!-- Operador (omitir para boolean) -->
{#if clause.attrId && kind !== 'boolean' && kind !== 'unknown' && ops.length > 0}
<select
value={clause.op}
onchange={(e) => patchClause(i, { op: e.currentTarget.value as ClauseOp })}
class="shrink-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1"
>
{#each ops as o}
<option value={o.op}>{o.label}</option>
{/each}
</select>
{/if}
<!-- Boolean: verdadero / falso -->
{#if clause.attrId && kind === 'boolean'}
<select
value={String(clause.boolValue)}
onchange={(e) => patchClause(i, { boolValue: e.currentTarget.value === 'true' })}
class="shrink-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1"
>
<option value="true">es verdadero</option>
<option value="false">es falso</option>
</select>
{/if}
<!-- Number -->
{#if clause.attrId && kind === 'number'}
<input
type="number"
value={clause.numValue}
oninput={(e) => patchClause(i, { numValue: Number(e.currentTarget.value) })}
class="w-20 shrink-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1"
/>
{/if}
<!-- String -->
{#if clause.attrId && kind === 'string'}
<input
type="text"
value={clause.strValue}
oninput={(e) => patchClause(i, { strValue: e.currentTarget.value })}
placeholder="valor..."
class="w-28 shrink-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1"
/>
{/if}
<!-- Eliminar -->
<button
onclick={() => removeClause(i)}
class="shrink-0 text-gray-600 hover:text-red-400 transition-colors text-xs px-1"
title="Eliminar cláusula"
>✕</button>
</div>
<!-- Chips de opciones (required / optional) -->
{#if clause.attrId && kind === 'options'}
<div class="flex flex-wrap gap-1 pt-0.5 pl-0.5">
{#if options.length === 0}
<span class="text-xs text-gray-700 italic">Sin opciones en este atributo</span>
{:else}
{#each options as opt (opt.id)}
{@const active = clause.optValues.includes(opt.id)}
<button
onclick={() => toggleOpt(i, opt.id, multi)}
class="text-xs px-2 py-0.5 rounded-full border transition-colors
{active
? 'bg-indigo-600/30 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
>
{optLabel(opt)}
</button>
{/each}
{/if}
</div>
{/if}
</div>
{/each}
<!-- Añadir cláusula -->
<button
onclick={addClause}
class="flex items-center gap-1.5 px-3 py-1.5 rounded border border-dashed
border-gray-700 text-xs text-gray-600 hover:text-indigo-400
hover:border-indigo-700 transition-colors"
>+ Añadir cláusula</button>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.