master
parent
41f07e6605
commit
4f2be98381
@ -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}
|
||||
@ -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>
|
||||
@ -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,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>
|
||||
@ -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,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>
|
||||
@ -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}
|
||||
@ -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…
Reference in new issue