master
parent
1db6022233
commit
9c0e24dab1
@ -1,12 +1,12 @@
|
||||
import type {LingLogger} from "@/ling/lib/types.ts";
|
||||
|
||||
|
||||
export const idPrefix = '#?';
|
||||
export const ID_PREFIX = '#?';
|
||||
|
||||
export const defaultISOLocale = 'es' ;
|
||||
export const DEFAULT_ISO_LOCALE = 'es' as const;
|
||||
|
||||
export const maxResolveDeep = 3;
|
||||
export const MAX_RESOLVE_DEEP = 3;
|
||||
|
||||
export const loggerCategory = 'ling';
|
||||
export const LOGGER_CATEGORY = 'ling';
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
// ==============================
|
||||
// HELPERS
|
||||
// ==============================
|
||||
import type {LingRecord} from "./types.ts";
|
||||
import {DEFAULT_ISO_LOCALE} from "@/ling/lib/consts.ts";
|
||||
|
||||
export function resolvePath(obj: any, path: string): any {
|
||||
return path.split('.').reduce((acc, key) => acc?.[key], obj);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function makeLingRecord (text?: string) : LingRecord {
|
||||
return {
|
||||
[DEFAULT_ISO_LOCALE]: text ?? ''
|
||||
} as LingRecord;
|
||||
}
|
||||
|
||||
@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import type { MetadataField, MetadataFieldType } from '../../lib';
|
||||
import { field as f } from '../../lib';
|
||||
import FieldTypeIcon from './FieldTypeIcon.svelte';
|
||||
import TypeBadge from './TypeBadge.svelte';
|
||||
import FieldValueEditor from './FieldValueEditor.svelte';
|
||||
|
||||
let {
|
||||
open = false,
|
||||
selectedType,
|
||||
existingKeys = [],
|
||||
locales = ['es'],
|
||||
defaultLocale = 'es',
|
||||
dark = false,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
open?: boolean;
|
||||
selectedType: MetadataFieldType;
|
||||
existingKeys?: string[];
|
||||
locales?: string[];
|
||||
defaultLocale?: string;
|
||||
dark?: boolean;
|
||||
onConfirm: (key: string, field: MetadataField) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let fieldKey = $state('');
|
||||
let fieldValue = $state<MetadataField>(defaultForType(selectedType));
|
||||
let keyError = $state('');
|
||||
|
||||
let wasOpen = $state(false);
|
||||
$effect(() => {
|
||||
if (open && !wasOpen) {
|
||||
fieldKey = suggestKey(selectedType);
|
||||
fieldValue = defaultForType(selectedType);
|
||||
keyError = '';
|
||||
}
|
||||
wasOpen = open;
|
||||
});
|
||||
|
||||
function suggestKey(type: MetadataFieldType): string {
|
||||
let n = 1;
|
||||
while (existingKeys.includes(`${type}_${n}`)) n++;
|
||||
return `${type}_${n}`;
|
||||
}
|
||||
|
||||
function todayISO(): string { return new Date().toISOString().slice(0, 10); }
|
||||
function nowISO(): string { return new Date().toISOString().slice(0, 16) + 'Z'; }
|
||||
|
||||
function defaultForType(type: MetadataFieldType): MetadataField {
|
||||
switch (type) {
|
||||
case 'string': return f.string('');
|
||||
case 'number': return f.number(0);
|
||||
case 'boolean': return f.boolean(false);
|
||||
case 'ling': return f.ling({ [defaultLocale]: '' } as any);
|
||||
case 'date': return f.date(todayISO());
|
||||
case 'datetime': return f.datetime(nowISO());
|
||||
case 'url': return f.url('');
|
||||
case 'currency': return f.currency({ amount: 0, currency: 'EUR' });
|
||||
case 'image': return f.image({ url: '' });
|
||||
case 'video': return f.video({ url: '' });
|
||||
case 'audio': return f.audio({ url: '' });
|
||||
case 'pdf': return f.pdf({ url: '' });
|
||||
case 'file': return f.file({ url: '' });
|
||||
case 'html': return f.html({ [defaultLocale]: '' } as any);
|
||||
case 'group': return f.group({});
|
||||
}
|
||||
}
|
||||
|
||||
function validateKey(): boolean {
|
||||
const trimmed = fieldKey.trim();
|
||||
if (!trimmed) { keyError = 'La clave no puede estar vacía'; return false; }
|
||||
if (existingKeys.includes(trimmed)){ keyError = `"${trimmed}" ya existe`; return false; }
|
||||
keyError = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!validateKey()) return;
|
||||
onConfirm(fieldKey.trim(), fieldValue);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (open && e.key === 'Escape') onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<dialog
|
||||
data-theme={dark ? 'dark' : 'light'}
|
||||
class={['modal', open ? 'modal-open' : ''].join(' ')}
|
||||
>
|
||||
<div class="modal-box max-w-md flex flex-col gap-0 p-0 overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-base-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-primary">
|
||||
<FieldTypeIcon type={selectedType} size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-base-content">Nuevo campo</h3>
|
||||
<div class="mt-0.5">
|
||||
<TypeBadge type={selectedType} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick={onClose} class="btn btn-ghost btn-sm btn-circle">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="overflow-y-auto px-5 py-4 flex flex-col gap-4">
|
||||
|
||||
<!-- Key -->
|
||||
<fieldset class="fieldset w-full">
|
||||
<legend class="fieldset-legend">
|
||||
Clave del campo
|
||||
{#if keyError}
|
||||
<span class="text-error ml-2 font-normal">{keyError}</span>
|
||||
{/if}
|
||||
</legend>
|
||||
<input
|
||||
bind:value={fieldKey}
|
||||
oninput={() => keyError = ''}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') handleConfirm(); }}
|
||||
class={['input input-bordered w-full font-mono', keyError ? 'input-error' : ''].join(' ')}
|
||||
placeholder="nombre_campo"
|
||||
autofocus
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<!-- Value editor -->
|
||||
<FieldValueEditor
|
||||
field={fieldValue}
|
||||
{locales} {defaultLocale}
|
||||
onFieldChange={(f) => fieldValue = f}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex gap-3 px-5 py-4 border-t border-base-200">
|
||||
<button type="button" onclick={onClose} class="btn btn-ghost flex-1">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="button" onclick={handleConfirm} class="btn btn-primary flex-1">
|
||||
Añadir campo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div class="modal-backdrop bg-black/40" onclick={onClose}></div>
|
||||
</dialog>
|
||||
@ -1,168 +1,118 @@
|
||||
<script lang="ts">
|
||||
import type { MetadataField, MetadataFieldType } from '../../lib';
|
||||
import LingInput from '@/ling/svelte/LingInput2.svelte';
|
||||
import type { MetadataField } from '../../lib';
|
||||
import FieldValueEditor from './FieldValueEditor.svelte';
|
||||
|
||||
let {
|
||||
fieldKey, field, locales, defaultLocale, onKeyChange, onFieldChange,
|
||||
fieldKey, field, locales, defaultLocale,
|
||||
attributes = [],
|
||||
onKeyChange, onFieldChange,
|
||||
}: {
|
||||
fieldKey: string;
|
||||
field: MetadataField;
|
||||
locales: string[];
|
||||
defaultLocale: string;
|
||||
attributes?: string[];
|
||||
onKeyChange: (newKey: string) => void;
|
||||
onFieldChange: (field: MetadataField) => void;
|
||||
} = $props();
|
||||
|
||||
let editKey = $state(fieldKey);
|
||||
let editKey = $state(fieldKey);
|
||||
let keyError = $state('');
|
||||
|
||||
function handleKeyBlur() {
|
||||
// Sincronizar con prop si cambia externamente (p.ej. tras rename en otro sitio)
|
||||
$effect(() => { editKey = fieldKey; });
|
||||
|
||||
let asBase = $derived(field as MetadataField & { hidden?: boolean; attrs?: string[] });
|
||||
let activeAttrs = $derived(asBase.attrs ?? []);
|
||||
|
||||
function handleKeyCommit(): void {
|
||||
const trimmed = editKey.trim();
|
||||
if (trimmed && trimmed !== fieldKey) onKeyChange(trimmed);
|
||||
else editKey = fieldKey;
|
||||
if (!trimmed) {
|
||||
keyError = 'La clave no puede estar vacía';
|
||||
editKey = fieldKey; // revertir
|
||||
return;
|
||||
}
|
||||
keyError = '';
|
||||
if (trimmed !== fieldKey) onKeyChange(trimmed);
|
||||
}
|
||||
|
||||
function update(partial: Partial<MetadataField>) {
|
||||
onFieldChange({ ...field, ...partial } as MetadataField);
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
e.stopPropagation(); // evitar que suba al header de la fila
|
||||
if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
|
||||
if (e.key === 'Escape') { editKey = fieldKey; keyError = ''; (e.currentTarget as HTMLInputElement).blur(); }
|
||||
}
|
||||
|
||||
function updateMediaUrl(url: string) {
|
||||
onFieldChange({ ...field, value: { ...(field as any).value, url } } as MetadataField);
|
||||
function update(partial: Partial<MetadataField & { hidden?: boolean; attrs?: string[] }>): void {
|
||||
onFieldChange({ ...field, ...partial } as MetadataField);
|
||||
}
|
||||
|
||||
// Clases comunes para inputs
|
||||
const inputCls = 'w-full rounded-lg border-2 border-zinc-200 bg-white px-3.5 py-2.5 text-[14px] text-zinc-800 transition-colors focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:focus:border-zinc-100';
|
||||
const labelCls = 'mb-1.5 block text-[12px] font-semibold uppercase tracking-wide text-zinc-400 dark:text-zinc-500';
|
||||
function toggleAttr(attr: string): void {
|
||||
const current = asBase.attrs ?? [];
|
||||
const next = current.includes(attr)
|
||||
? current.filter(a => a !== attr)
|
||||
: [...current, attr];
|
||||
update({ attrs: next });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 px-5 py-4">
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
|
||||
<!-- Key -->
|
||||
<div>
|
||||
<label class={labelCls}>Clave del campo</label>
|
||||
<fieldset class="fieldset w-full">
|
||||
<legend class="fieldset-legend">
|
||||
Clave del campo
|
||||
{#if keyError}
|
||||
<span class="text-error ml-2 font-normal text-xs">{keyError}</span>
|
||||
{/if}
|
||||
</legend>
|
||||
<input
|
||||
bind:value={editKey}
|
||||
onblur={handleKeyBlur}
|
||||
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
|
||||
class="{inputCls} font-mono"
|
||||
value={editKey}
|
||||
oninput={(e) => { editKey = (e.currentTarget as HTMLInputElement).value; keyError = ''; }}
|
||||
onblur={handleKeyCommit}
|
||||
onkeydown={handleKeydown}
|
||||
class={['input input-bordered w-full font-mono', keyError ? 'input-error' : ''].join(' ')}
|
||||
placeholder="nombre_campo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Value per type -->
|
||||
{#if field.type === 'string'}
|
||||
<div>
|
||||
<label class={labelCls}>Valor</label>
|
||||
<input type="text" value={field.value}
|
||||
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
|
||||
class={inputCls} placeholder="Texto…" />
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'number'}
|
||||
<div>
|
||||
<label class={labelCls}>Valor numérico</label>
|
||||
<input type="number" value={field.value}
|
||||
oninput={(e) => update({ value: parseFloat((e.currentTarget as HTMLInputElement).value) })}
|
||||
class={inputCls} />
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'boolean'}
|
||||
<div>
|
||||
<label class={labelCls}>Valor</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" onclick={() => update({ value: !field.value })}
|
||||
class={['relative h-7 w-12 rounded-full transition-colors duration-200',
|
||||
field.value ? 'bg-zinc-900 dark:bg-zinc-100' : 'bg-zinc-200 dark:bg-zinc-700'].join(' ')}>
|
||||
<span class={['absolute top-1 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
|
||||
field.value ? 'translate-x-6 dark:bg-zinc-900' : 'translate-x-1'].join(' ')}></span>
|
||||
</button>
|
||||
<span class="text-[14px] font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{field.value ? 'Verdadero' : 'Falso'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'lingstring'}
|
||||
<LingInput
|
||||
value={field.value as any}
|
||||
{locales}
|
||||
{defaultLocale}
|
||||
label="Valor multiidioma"
|
||||
as="input"
|
||||
onUpdate={(val) => update({ value: val })}
|
||||
/>
|
||||
|
||||
{:else if field.type === 'date'}
|
||||
<div>
|
||||
<label class={labelCls}>Fecha</label>
|
||||
<input type="date" value={field.value}
|
||||
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
|
||||
class={inputCls} />
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'datetime'}
|
||||
<div>
|
||||
<label class={labelCls}>Fecha y hora</label>
|
||||
<input type="datetime-local" value={field.value?.replace('Z', '')}
|
||||
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value + 'Z' })}
|
||||
class={inputCls} />
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'url'}
|
||||
<div>
|
||||
<label class={labelCls}>URL</label>
|
||||
<input type="text" value={field.value}
|
||||
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
|
||||
class={inputCls} placeholder="https://… o /ruta/relativa" />
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'currency'}
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class={labelCls}>Cantidad</label>
|
||||
<input type="number" value={field.value.amount}
|
||||
oninput={(e) => update({ value: { ...field.value, amount: parseFloat((e.currentTarget as HTMLInputElement).value) } })}
|
||||
class={inputCls} />
|
||||
</div>
|
||||
<div class="w-28">
|
||||
<label class={labelCls}>Moneda (ISO)</label>
|
||||
<input type="text" maxlength="3" value={field.value.currency}
|
||||
oninput={(e) => update({ value: { ...field.value, currency: (e.currentTarget as HTMLInputElement).value.toUpperCase() } })}
|
||||
class="{inputCls} font-mono uppercase" placeholder="EUR" />
|
||||
</fieldset>
|
||||
|
||||
<!-- Value editor by type -->
|
||||
<FieldValueEditor {field} {locales} {defaultLocale} {onFieldChange} />
|
||||
|
||||
<!-- Attributes -->
|
||||
{#if attributes.length > 0}
|
||||
<div class="divider my-0"></div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-base-content/50">Atributos</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each attributes as attr (attr)}
|
||||
{@const active = activeAttrs.includes(attr)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleAttr(attr)}
|
||||
class={['badge badge-md cursor-pointer select-none transition-colors',
|
||||
active ? 'badge-primary' : 'badge-ghost border border-base-300'
|
||||
].join(' ')}
|
||||
>
|
||||
{#if active}
|
||||
<svg class="mr-1 h-3 w-3" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M2 6l3 3 5-5"/>
|
||||
</svg>
|
||||
{/if}
|
||||
{attr}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'image' || field.type === 'video' || field.type === 'audio' || field.type === 'pdf' || field.type === 'file'}
|
||||
<div>
|
||||
<label class={labelCls}>Ruta o URL</label>
|
||||
<input type="text" value={field.value.url}
|
||||
oninput={(e) => updateMediaUrl((e.currentTarget as HTMLInputElement).value)}
|
||||
class={inputCls} placeholder="/ruta/relativa o https://…" />
|
||||
</div>
|
||||
{#if field.type === 'image' && field.value.url}
|
||||
<div class="overflow-hidden rounded-xl border-2 border-zinc-100 dark:border-zinc-800">
|
||||
<img src={field.value.url} alt={fieldKey}
|
||||
class="max-h-52 w-full object-cover" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else if field.type === 'group'}
|
||||
<p class="text-[13px] text-zinc-400">
|
||||
Edita los campos del grupo expandiéndolos en la lista.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Hidden toggle -->
|
||||
<div class="flex items-center justify-between border-t border-zinc-100 pt-4 dark:border-zinc-800">
|
||||
<div>
|
||||
<p class="text-[13px] font-semibold text-zinc-700 dark:text-zinc-300">Ocultar campo</p>
|
||||
<p class="text-[12px] text-zinc-400">El campo no se mostrará en la UI</p>
|
||||
</div>
|
||||
<button type="button" onclick={() => update({ hidden: !field.hidden })}
|
||||
class={['relative h-7 w-12 rounded-full transition-colors duration-200',
|
||||
field.hidden ? 'bg-zinc-900 dark:bg-zinc-100' : 'bg-zinc-200 dark:bg-zinc-700'].join(' ')}>
|
||||
<span class={['absolute top-1 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
|
||||
field.hidden ? 'translate-x-6 dark:bg-zinc-900' : 'translate-x-1'].join(' ')}></span>
|
||||
</button>
|
||||
<div class="divider my-0"></div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span>Ocultar campo</span>
|
||||
<input type="checkbox" checked={asBase.hidden ?? false}
|
||||
onchange={(e) => update({ hidden: (e.currentTarget as HTMLInputElement).checked })}
|
||||
class="toggle toggle-sm" />
|
||||
<span class="text-base-content/50">{asBase.hidden ? 'No se muestra en la UI' : 'Visible'}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
<!-- FieldTypeIcon.svelte — icono SVG por tipo de campo -->
|
||||
<script lang="ts">
|
||||
import type { MetadataFieldType } from 'meta-content';
|
||||
|
||||
let {
|
||||
type,
|
||||
size = 16,
|
||||
class: cls = '',
|
||||
}: {
|
||||
type: MetadataFieldType;
|
||||
size?: number;
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if type === 'string'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'number'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'boolean'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<rect x="1" y="5" width="22" height="14" rx="7"/><circle cx="16" cy="12" r="3" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'ling'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'date'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'datetime'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||
<polyline points="12 14 12 17 14 17"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'url'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'currency'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'image'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'video'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'audio'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'pdf'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="9" y1="15" x2="15" y2="15"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'file'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'html'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
|
||||
</svg>
|
||||
|
||||
{:else if type === 'group'}
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
|
||||
<rect x="2" y="3" width="8" height="8" rx="1"/><rect x="14" y="3" width="8" height="8" rx="1"/>
|
||||
<rect x="2" y="13" width="8" height="8" rx="1"/><rect x="14" y="13" width="8" height="8" rx="1"/>
|
||||
</svg>
|
||||
{/if}
|
||||
@ -1,32 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { MetadataFieldType } from '../../lib';
|
||||
import type {MetadataField, MetadataFieldType} from '../../lib';
|
||||
import FieldTypeIcon from './FieldTypeIcon.svelte';
|
||||
|
||||
let { type }: { type: MetadataFieldType } = $props();
|
||||
let {
|
||||
type,
|
||||
showLabel = true,
|
||||
}: {
|
||||
type: MetadataFieldType;
|
||||
showLabel?: boolean;
|
||||
} = $props();
|
||||
|
||||
const config: Record<MetadataFieldType, { label: string; classes: string }> = {
|
||||
string: { label: 'STR', classes: 'bg-sky-500/10 text-sky-400 border-sky-500/20' },
|
||||
number: { label: 'NUM', classes: 'bg-violet-500/10 text-violet-400 border-violet-500/20' },
|
||||
boolean: { label: 'BOOL', classes: 'bg-amber-500/10 text-amber-400 border-amber-500/20' },
|
||||
lingstring: { label: 'LING', classes: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' },
|
||||
date: { label: 'DATE', classes: 'bg-orange-500/10 text-orange-400 border-orange-500/20' },
|
||||
datetime: { label: 'DTIME', classes: 'bg-rose-500/10 text-rose-400 border-rose-500/20' },
|
||||
url: { label: 'URL', classes: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20' },
|
||||
currency: { label: 'CUR', classes: 'bg-lime-500/10 text-lime-400 border-lime-500/20' },
|
||||
image: { label: 'IMG', classes: 'bg-pink-500/10 text-pink-400 border-pink-500/20' },
|
||||
video: { label: 'VID', classes: 'bg-red-500/10 text-red-400 border-red-500/20' },
|
||||
audio: { label: 'AUD', classes: 'bg-purple-500/10 text-purple-400 border-purple-500/20' },
|
||||
pdf: { label: 'PDF', classes: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20' },
|
||||
file: { label: 'FILE', classes: 'bg-teal-500/10 text-teal-400 border-teal-500/20' },
|
||||
group: { label: 'GROUP', classes: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/20' },
|
||||
string: { label: 'Texto', classes: 'badge-info' },
|
||||
number: { label: 'Número', classes: 'badge-secondary' },
|
||||
boolean: { label: 'Bool', classes: 'badge-warning' },
|
||||
ling: { label: 'i18n', classes: 'badge-success' },
|
||||
date: { label: 'Fecha', classes: 'badge-accent' },
|
||||
datetime: { label: 'DateTime', classes: 'badge-accent' },
|
||||
url: { label: 'URL', classes: 'badge-info' },
|
||||
currency: { label: 'Moneda', classes: 'badge-success' },
|
||||
image: { label: 'Imagen', classes: 'badge-error' },
|
||||
video: { label: 'Vídeo', classes: 'badge-error' },
|
||||
audio: { label: 'Audio', classes: 'badge-secondary' },
|
||||
pdf: { label: 'PDF', classes: 'badge-neutral' },
|
||||
file: { label: 'Fichero', classes: 'badge-neutral' },
|
||||
html: { label: 'HTML', classes: 'badge-warning' },
|
||||
group: { label: 'Grupo', classes: 'badge-primary' },
|
||||
};
|
||||
|
||||
let cfg = $derived(config[type]);
|
||||
</script>
|
||||
|
||||
<span class={[
|
||||
'inline-flex items-center rounded border px-1.5 py-0.5',
|
||||
'font-mono text-[9px] font-bold uppercase tracking-widest',
|
||||
cfg.classes,
|
||||
].join(' ')}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
<span class={['badge badge-soft badge-sm gap-1'].join(' ')}>
|
||||
<FieldTypeIcon {type} size={12} />
|
||||
{#if showLabel}
|
||||
<span class="">{cfg.label}</span>
|
||||
{/if}
|
||||
</span>
|
||||
@ -1,138 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · Svelte Adapter
|
||||
//
|
||||
// Envuelve ViceEngine con $state de Svelte 5.
|
||||
// Proporciona un store reactivo para el configurador.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { createViceEngine } from '@/vice/libs/./engine.ts'
|
||||
import type {
|
||||
ViceSession,
|
||||
Collection,
|
||||
EntityID,
|
||||
OptionID,
|
||||
EntityPathKey,
|
||||
LoadResult,
|
||||
SessionState,
|
||||
SelectResult,
|
||||
DeselectResult,
|
||||
PropagationResult,
|
||||
DesignIssue
|
||||
} from '@/vice/libs'
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE STORE
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createViceStore(collection: Collection, entityId: EntityID) {
|
||||
|
||||
const engine = createViceEngine()
|
||||
const loadResult = engine.load(collection, entityId)
|
||||
|
||||
if (!loadResult.valid) {
|
||||
const errors = loadResult.issues
|
||||
.filter((i : DesignIssue) => i.severity === 'error')
|
||||
.map((i: DesignIssue) => i.message)
|
||||
.join('\n')
|
||||
throw new Error(`[VICE] Cannot create store — graph has errors:\n${errors}`)
|
||||
}
|
||||
|
||||
const session: ViceSession = engine.createSession()
|
||||
|
||||
// ── Reactive state ─────────────────────────────────────────────────────────
|
||||
|
||||
let state = $state<SessionState>(session.getState())
|
||||
let lastResult = $state<PropagationResult | null>(null)
|
||||
let lastError = $state<SelectResult | DeselectResult | null>(null)
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
function select(path: EntityPathKey, optionId: OptionID, quantity?: number): SelectResult {
|
||||
const result = session.select(path, optionId, quantity)
|
||||
if (result.ok) {
|
||||
state = result.result.state
|
||||
lastResult = result.result
|
||||
lastError = null
|
||||
} else {
|
||||
lastError = result
|
||||
lastResult = null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function deselect(path: EntityPathKey): DeselectResult {
|
||||
const result = session.deselect(path)
|
||||
if (result.ok) {
|
||||
state = result.result.state
|
||||
lastResult = result.result
|
||||
lastError = null
|
||||
} else {
|
||||
lastError = result
|
||||
lastResult = null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function reset(): SessionState {
|
||||
const s = session.reset()
|
||||
state = s
|
||||
lastResult = null
|
||||
lastError = null
|
||||
return s
|
||||
}
|
||||
|
||||
// ── Derived helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const consistent = $derived(state.consistent)
|
||||
const hasViolations = $derived(
|
||||
[...state.attributes.values()].some(a => a.violations.length > 0)
|
||||
)
|
||||
const violations = $derived(
|
||||
[...state.attributes.values()].flatMap(a =>
|
||||
a.violations.map(v => ({ ...v, path: a.path, attrId: a.attrId }))
|
||||
)
|
||||
)
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
// Load info
|
||||
loadResult: loadResult as LoadResult,
|
||||
|
||||
// Reactive state
|
||||
get state() { return state },
|
||||
get consistent() { return consistent },
|
||||
get hasViolations() { return hasViolations },
|
||||
get violations() { return violations },
|
||||
get lastResult() { return lastResult },
|
||||
get lastError() { return lastError },
|
||||
|
||||
// Actions
|
||||
select,
|
||||
deselect,
|
||||
reset,
|
||||
|
||||
// Convenience accessors
|
||||
getAttribute(path: EntityPathKey) {
|
||||
return state.attributes.get(path) ?? null
|
||||
},
|
||||
getGroup(path: EntityPathKey) {
|
||||
return state.groups.get(path) ?? null
|
||||
},
|
||||
isActive(path: EntityPathKey): boolean {
|
||||
return state.attributes.get(path)?.active
|
||||
?? state.groups.get(path)?.active
|
||||
?? false
|
||||
},
|
||||
currentOption(path: EntityPathKey): OptionID | null {
|
||||
return state.attributes.get(path)?.current?.optionId ?? null
|
||||
},
|
||||
domain(path: EntityPathKey): ReadonlySet<OptionID> {
|
||||
return state.attributes.get(path)?.domain ?? new Set()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ViceStore = ReturnType<typeof createViceStore>
|
||||
@ -1,223 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · ViceStore Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { fixture } from '@/vice/libs//fixture.ts'
|
||||
import { createViceStore } from './ViceStore.svelte.ts'
|
||||
import type { ViceStore } from './ViceStore.svelte.ts'
|
||||
|
||||
let vice: ViceStore
|
||||
|
||||
beforeEach(() => {
|
||||
vice = createViceStore(fixture, 'en_bmw430')
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Inicialización
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('inicialización', () => {
|
||||
it('DEBUG — violaciones tras seleccionar op_coupe', () => {
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
console.log('consistent:', vice.consistent)
|
||||
console.log('violations:', vice.violations)
|
||||
console.log('state attributes:')
|
||||
for (const [path, attr] of vice.state.attributes) {
|
||||
if (attr.violations.length > 0 || !attr.active) {
|
||||
console.log(' ', path, '| active:', attr.active, '| domain:', [...attr.domain], '| violations:', attr.violations)
|
||||
}
|
||||
}
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('loadResult.valid = true para fixture válido', () => {
|
||||
expect(vice.loadResult.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('lanza si el catálogo tiene errores de diseño', () => {
|
||||
const bad = {
|
||||
...fixture,
|
||||
entities: {
|
||||
'en_bad': {
|
||||
id: 'en_bad' as any,
|
||||
code: 'bad',
|
||||
name: { es: 'Bad' },
|
||||
description: { es: '' },
|
||||
attributes: [{
|
||||
id: 'at_x' as any,
|
||||
code: 'x',
|
||||
type: 'required' as const,
|
||||
name: { es: 'X' },
|
||||
description: { es: '' },
|
||||
display: {},
|
||||
default: 'op_inexistente' as any,
|
||||
options: [{ optionId: 'op_blanco' as any }],
|
||||
}],
|
||||
groups: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
expect(() => createViceStore(bad as any, 'en_bad' as any)).toThrow('[VICE]')
|
||||
})
|
||||
|
||||
it('estado inicial es consistente', () => {
|
||||
expect(vice.consistent).toBe(true)
|
||||
})
|
||||
|
||||
it('estado inicial sin violaciones', () => {
|
||||
expect(vice.hasViolations).toBe(false)
|
||||
})
|
||||
|
||||
it('at_version empieza con default op_berlina', () => {
|
||||
expect(vice.currentOption('en_bmw430/at_version')).toBe('op_berlina')
|
||||
})
|
||||
|
||||
it('gr_extras empieza inactivo', () => {
|
||||
expect(vice.isActive('en_bmw430/gr_extras')).toBe(false)
|
||||
})
|
||||
|
||||
it('gr_motor empieza activo', () => {
|
||||
expect(vice.isActive('en_bmw430/gr_motor')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// select
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('select', () => {
|
||||
|
||||
it('devuelve ok=true para selección válida', () => {
|
||||
const result = vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('actualiza state tras selección', () => {
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
expect(vice.currentOption('en_bmw430/at_version')).toBe('op_coupe')
|
||||
})
|
||||
|
||||
it('propaga — seleccionar coupe activa gr_extras', () => {
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
expect(vice.isActive('en_bmw430/gr_extras')).toBe(true)
|
||||
})
|
||||
|
||||
it('propaga — seleccionar electrico restringe potencia', () => {
|
||||
vice.select('en_bmw430/gr_motor/at_combustible', 'op_electrico' as any)
|
||||
const domain = vice.domain('en_bmw430/gr_motor/at_potencia')
|
||||
expect(domain.has('op_150cv' as any)).toBe(false)
|
||||
expect(domain.has('op_190cv' as any)).toBe(false)
|
||||
})
|
||||
|
||||
it('devuelve ok=false con INVALID_PATH', () => {
|
||||
const result = vice.select('en_bmw430/at_inexistente', 'op_x' as any)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('INVALID_PATH')
|
||||
})
|
||||
|
||||
it('devuelve ok=false con INACTIVE_ATTRIBUTE', () => {
|
||||
const result = vice.select('en_bmw430/gr_tecnologia/at_sonido', 'op_harman' as any)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('INACTIVE_ATTRIBUTE')
|
||||
})
|
||||
|
||||
it('devuelve ok=false con OPTION_NOT_IN_DOMAIN', () => {
|
||||
vice.select('en_bmw430/gr_motor/at_combustible', 'op_electrico' as any)
|
||||
const result = vice.select('en_bmw430/gr_motor/at_potencia', 'op_150cv' as any)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('OPTION_NOT_IN_DOMAIN')
|
||||
})
|
||||
|
||||
it('guarda lastError tras selección fallida', () => {
|
||||
vice.select('en_bmw430/at_inexistente', 'op_x' as any)
|
||||
expect(vice.lastError).not.toBeNull()
|
||||
expect(vice.lastError?.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('limpia lastError tras selección exitosa', () => {
|
||||
vice.select('en_bmw430/at_inexistente', 'op_x' as any)
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
expect(vice.lastError).toBeNull()
|
||||
})
|
||||
|
||||
it('guarda lastResult tras selección exitosa', () => {
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
expect(vice.lastResult).not.toBeNull()
|
||||
expect(vice.lastResult?.consistent).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// deselect
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deselect', () => {
|
||||
|
||||
it('devuelve ok=false con INVALID_PATH', () => {
|
||||
const result = vice.deselect('en_bmw430/at_inexistente')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('INVALID_PATH')
|
||||
})
|
||||
|
||||
it('pone current = null tras deselect', () => {
|
||||
vice.deselect('en_bmw430/at_version')
|
||||
expect(vice.currentOption('en_bmw430/at_version')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// reset
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('reset', () => {
|
||||
|
||||
it('vuelve al estado inicial', () => {
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
vice.select('en_bmw430/gr_motor/at_combustible', 'op_electrico' as any)
|
||||
vice.reset()
|
||||
|
||||
expect(vice.currentOption('en_bmw430/at_version')).toBe('op_berlina')
|
||||
expect(vice.currentOption('en_bmw430/gr_motor/at_combustible')).toBe('op_gasolina')
|
||||
expect(vice.isActive('en_bmw430/gr_extras')).toBe(false)
|
||||
})
|
||||
|
||||
it('limpia lastResult y lastError tras reset', () => {
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
vice.reset()
|
||||
expect(vice.lastResult).toBeNull()
|
||||
expect(vice.lastError).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('helpers', () => {
|
||||
|
||||
it('getAttribute devuelve el estado del atributo', () => {
|
||||
const attr = vice.getAttribute('en_bmw430/at_version')
|
||||
expect(attr).not.toBeNull()
|
||||
expect(attr?.attrId).toBe('at_version')
|
||||
})
|
||||
|
||||
it('getAttribute devuelve null para path inexistente', () => {
|
||||
expect(vice.getAttribute('en_bmw430/at_inexistente')).toBeNull()
|
||||
})
|
||||
|
||||
it('getGroup devuelve el estado del grupo', () => {
|
||||
const group = vice.getGroup('en_bmw430/gr_motor')
|
||||
expect(group).not.toBeNull()
|
||||
expect(group?.groupId).toBe('gr_motor')
|
||||
})
|
||||
|
||||
it('domain devuelve set vacío para path inexistente', () => {
|
||||
expect(vice.domain('en_bmw430/at_inexistente').size).toBe(0)
|
||||
})
|
||||
|
||||
it('violations agrupa todas las violaciones con path', () => {
|
||||
expect(Array.isArray(vice.violations)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,378 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ATTRIBUTE DETAIL — vice2
|
||||
*/
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore } from '../stores/editor.svelte'
|
||||
import type {
|
||||
RequiredAttribute,
|
||||
OptionalAttribute,
|
||||
FixedAttribute,
|
||||
OptionID,
|
||||
} from '@/vice/libs/types'
|
||||
import RuleList from './RuleList.svelte'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
const attr = $derived(editor.activeAttribute!)
|
||||
const group = $derived(editor.activeGroup)
|
||||
const entity = $derived(editor.activeEntity)
|
||||
const langs = $derived(editor.collection.langs)
|
||||
|
||||
// Helper: obtiene el nombre localizado
|
||||
function ling(l: Record<string, string> | undefined, lang: string): string {
|
||||
if (!l) return ''
|
||||
return l[lang] ?? l['es'] ?? Object.values(l)[0] ?? ''
|
||||
}
|
||||
|
||||
function hasOptions(a: typeof attr): a is RequiredAttribute | OptionalAttribute {
|
||||
return a.type === 'required' || a.type === 'optional'
|
||||
}
|
||||
|
||||
function patch(key: string, value: unknown) {
|
||||
if (entity) {
|
||||
editor.updateAttribute(entity.id, group?.id, attr.id, { [key]: value })
|
||||
} else {
|
||||
editor.updateCatalogAttribute(attr.id, { [key]: value })
|
||||
}
|
||||
}
|
||||
|
||||
function patchI18n(field: 'name' | 'description', lang: string, value: string) {
|
||||
const current = (attr[field] as Record<string, string>) ?? {}
|
||||
const update = { [field]: { ...current, [lang]: value } }
|
||||
if (entity) {
|
||||
editor.updateAttribute(entity.id, group?.id, attr.id, update)
|
||||
} else {
|
||||
editor.updateCatalogAttribute(attr.id, update)
|
||||
}
|
||||
if (field === 'name' && lang === langs[0] && !attr.code) {
|
||||
const codeUpdate = {
|
||||
code: value.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, ''),
|
||||
}
|
||||
if (entity) {
|
||||
editor.updateAttribute(entity.id, group?.id, attr.id, codeUpdate)
|
||||
} else {
|
||||
editor.updateCatalogAttribute(attr.id, codeUpdate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function patchDisplay(key: string, value: boolean) {
|
||||
const update = { display: { ...attr.display, [key]: value } }
|
||||
if (entity) {
|
||||
editor.updateAttribute(entity.id, group?.id, attr.id, update)
|
||||
} else {
|
||||
editor.updateCatalogAttribute(attr.id, update)
|
||||
}
|
||||
}
|
||||
|
||||
// options es Record<OptionID, OptionDefinition>
|
||||
const allOptions = $derived(Object.values(editor.collection.options))
|
||||
const assignedOptIds = $derived.by(() => {
|
||||
if (!hasOptions(attr)) return new Set<string>()
|
||||
return new Set((attr as RequiredAttribute).options.map(o => o.optionId))
|
||||
})
|
||||
const availableOptions = $derived.by(() => allOptions.filter(o => !assignedOptIds.has(o.id)))
|
||||
|
||||
let optPickerOpen = $state(false)
|
||||
let optPickerSearch = $state('')
|
||||
|
||||
const filteredPicker = $derived.by(() => {
|
||||
const s = optPickerSearch.toLowerCase()
|
||||
return availableOptions.filter(o =>
|
||||
!s ||
|
||||
ling(o.name as Record<string, string>, langs[0]).toLowerCase().includes(s) ||
|
||||
o.code.toLowerCase().includes(s)
|
||||
)
|
||||
})
|
||||
|
||||
function addOption(optionId: OptionID) {
|
||||
if (entity) editor.addOptionToAttribute(entity.id, group?.id, attr.id, optionId)
|
||||
optPickerOpen = false
|
||||
optPickerSearch = ''
|
||||
}
|
||||
|
||||
function removeOption(optionId: OptionID) {
|
||||
if (entity) editor.removeOptionFromAttribute(entity.id, group?.id, attr.id, optionId)
|
||||
}
|
||||
|
||||
function optName(id: string): string {
|
||||
const opt = editor.collection.options[id as OptionID]
|
||||
if (!opt) return id
|
||||
return ling(opt.name as Record<string, string>, langs[0]) || opt.code
|
||||
}
|
||||
|
||||
function optPrice(id: string): string | null {
|
||||
const opt = editor.collection.options[id as OptionID]
|
||||
if (!opt?.pricing) return null
|
||||
if (opt.pricing.dynamicExpression) return 'din'
|
||||
const amount = opt.pricing.baseAmount
|
||||
return amount === 'consultation' ? '?' : `${amount}€`
|
||||
}
|
||||
|
||||
const attrDisplayName = $derived(
|
||||
ling(attr.name as Record<string, string>, langs[0]) || 'Atributo sin nombre'
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-2xl flex flex-col gap-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-200">{attrDisplayName}</h2>
|
||||
<span class="text-xs text-gray-600 font-mono">{attr.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">
|
||||
Código {#if !attr.code}<span class="text-red-400 ml-1">*</span>{/if}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={attr.code}
|
||||
oninput={(e) => patch('code', e.currentTarget.value)}
|
||||
placeholder="suelo"
|
||||
class="field-input font-mono text-xs {!attr.code ? 'border-red-800' : ''}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{#each langs as lang (lang)}
|
||||
{@const val = (attr.name as Record<string, string>)?.[lang] ?? ''}
|
||||
{@const required = lang === langs[0] && !val}
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">
|
||||
Nombre ({lang}){#if required}<span class="text-red-400 ml-1">*</span>{/if}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={val}
|
||||
oninput={(e) => patchI18n('name', lang, e.currentTarget.value)}
|
||||
placeholder="Suelo"
|
||||
class="field-input {required ? 'border-red-800' : ''}"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">Descripción ({langs[0]})</label>
|
||||
<textarea
|
||||
oninput={(e) => patchI18n('description', langs[0], e.currentTarget.value)}
|
||||
placeholder="Material del suelo..."
|
||||
rows="2"
|
||||
class="field-input resize-none"
|
||||
>{(attr.description as Record<string, string>)?.[langs[0]] ?? ''}</textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tipo -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="section-title">Tipo</h3>
|
||||
<div class="flex gap-2">
|
||||
{#each (['required', 'optional', 'fixed'] as const) as t}
|
||||
<button
|
||||
onclick={() => patch('type', t)}
|
||||
class="flex-1 py-2 text-xs rounded-md border transition-colors
|
||||
{attr.type === t
|
||||
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
|
||||
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
|
||||
>
|
||||
{t === 'required' ? 'Required' : t === 'optional' ? 'Optional' : 'Fixed'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 italic">
|
||||
{#if attr.type === 'required'}El usuario debe elegir. null = configuración incompleta.
|
||||
{:else if attr.type === 'optional'}El usuario activa o desactiva.
|
||||
{:else}Valor constante. Puede ser fuente de constraints.{/if}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Comportamiento -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="section-title">Comportamiento</h3>
|
||||
{#each ([
|
||||
['uiVisible', 'Visible en UI', '— Mostrar en el configurador'],
|
||||
['affectsVisual', 'Afecta a la imagen', '— Cambiar esta opción actualiza el render'],
|
||||
['affectsPrice', 'Afecta al precio', '— Cambiar esta opción recalcula el precio'],
|
||||
] as const) as [key, label, hint]}
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!(attr.display as Record<string, unknown>)?.[key]}
|
||||
onchange={(e) => patchDisplay(key, e.currentTarget.checked)}
|
||||
class="accent-indigo-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">{label}</span>
|
||||
<span class="text-xs text-gray-600">{hint}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<!-- Opciones (required / optional) -->
|
||||
{#if hasOptions(attr)}
|
||||
{@const attrWithOpts = attr as RequiredAttribute}
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="section-title">
|
||||
Opciones <span class="text-gray-600 font-normal">({attrWithOpts.options.length})</span>
|
||||
</h3>
|
||||
|
||||
{#if attrWithOpts.options.length === 0}
|
||||
<p class="text-xs text-gray-600 italic">Sin opciones — añade desde la librería</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each attrWithOpts.options as attrOpt (attrOpt.optionId)}
|
||||
{@const price = optPrice(attrOpt.optionId)}
|
||||
<div class="rounded-md border border-gray-800 bg-gray-900/50 px-3 py-2
|
||||
flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm text-gray-200 truncate">{optName(attrOpt.optionId)}</span>
|
||||
<span class="text-xs text-gray-600 font-mono shrink-0">
|
||||
{editor.collection.options[attrOpt.optionId]?.code ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{#if price}
|
||||
<span class="text-xs {price === 'din' ? 'text-indigo-400/70' : 'text-gray-500'}">{price}</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => removeOption(attrOpt.optionId)}
|
||||
class="text-gray-600 hover:text-red-400 transition-colors text-xs"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Picker -->
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => optPickerOpen = !optPickerOpen}
|
||||
class="w-full flex items-center justify-between px-3 py-2 rounded-md border
|
||||
border-dashed border-gray-700 text-xs text-gray-500 hover:text-gray-300
|
||||
hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<span>+ Añadir opción de la librería</span>
|
||||
<span class="text-gray-600">{availableOptions.length} disponibles</span>
|
||||
</button>
|
||||
|
||||
{#if optPickerOpen}
|
||||
<button
|
||||
class="fixed inset-0 z-40 cursor-default"
|
||||
onclick={() => optPickerOpen = false}
|
||||
aria-label="Cerrar"
|
||||
></button>
|
||||
<div class="absolute top-full left-0 right-0 mt-1 z-50 bg-gray-900 border
|
||||
border-gray-700 rounded-md shadow-xl overflow-hidden">
|
||||
<div class="p-2 border-b border-gray-800">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={optPickerSearch}
|
||||
placeholder="Buscar opción..."
|
||||
class="w-full bg-gray-800 text-sm text-gray-200 px-3 py-1.5 rounded
|
||||
border border-gray-700 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<ul class="max-h-52 overflow-y-auto">
|
||||
{#if filteredPicker.length === 0}
|
||||
<li class="px-4 py-3 text-xs text-gray-600 text-center">
|
||||
{allOptions.length === 0 ? 'No hay opciones — créalas en el panel Opciones' : 'Sin resultados'}
|
||||
</li>
|
||||
{:else}
|
||||
{#each filteredPicker as opt (opt.id)}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => addOption(opt.id)}
|
||||
class="w-full text-left px-4 py-2 flex items-center justify-between
|
||||
text-sm text-gray-300 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<span>{ling(opt.name as Record<string, string>, langs[0])}</span>
|
||||
<span class="text-xs text-gray-600 font-mono">{opt.code}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Valor por defecto -->
|
||||
{#if attrWithOpts.options.length > 0}
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">
|
||||
Valor por defecto
|
||||
{#if attr.type === 'required'}<span class="text-red-400 ml-1">*</span>{/if}
|
||||
</label>
|
||||
<select
|
||||
onchange={(e) => patch('default', e.currentTarget.value || null)}
|
||||
class="field-input"
|
||||
>
|
||||
{#if attr.type === 'optional'}
|
||||
<option value="" selected={!attrWithOpts.default}>Sin valor por defecto</option>
|
||||
{/if}
|
||||
{#each attrWithOpts.options as attrOpt (attrOpt.optionId)}
|
||||
<option value={attrOpt.optionId} selected={attrWithOpts.default === attrOpt.optionId}>
|
||||
{optName(attrOpt.optionId)}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Valor fijo -->
|
||||
{#if attr.type === 'fixed'}
|
||||
{@const fixedAttr = attr as FixedAttribute}
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="section-title">Valor fijo</h3>
|
||||
<select
|
||||
onchange={(e) => patch('option', { optionId: e.currentTarget.value })}
|
||||
class="field-input"
|
||||
>
|
||||
<option value="" selected={!fixedAttr.option?.optionId}>Sin opción...</option>
|
||||
{#each allOptions as opt (opt.id)}
|
||||
<option value={opt.id} selected={fixedAttr.option?.optionId === opt.id}>
|
||||
{ling(opt.name as Record<string, string>, langs[0]) || opt.id} — {opt.code}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-xs text-gray-600 italic">
|
||||
El valor constante no puede ser modificado por el usuario.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Reglas ────────────────────────────────────────────────────────── -->
|
||||
{#if entity}
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="section-title">
|
||||
Reglas
|
||||
<span class="text-gray-600 font-normal">
|
||||
({((attr as any).rules ?? []).length})
|
||||
</span>
|
||||
</h3>
|
||||
<RuleList
|
||||
rules={(attr as any).rules ?? []}
|
||||
entityId={entity.id}
|
||||
groupId={group?.id}
|
||||
attrId={attr.id}
|
||||
/>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="section-title">Reglas</h3>
|
||||
<p class="text-xs text-gray-600 italic">
|
||||
Los atributos de catálogo no tienen reglas — son el nivel raíz.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
@ -1,141 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ATTRIBUTE LIST — tres niveles
|
||||
*
|
||||
* Muestra atributos de:
|
||||
* 1. Catálogo (collection.attributes) — solo si no hay entidad activa
|
||||
* 2. Entidad (entity.attributes) — si hay entidad activa
|
||||
* 3. Grupo (group.attributes) — si hay grupo activo
|
||||
*
|
||||
* La columna izquierda muestra la lista.
|
||||
* La columna derecha muestra AttributeDetail del atributo activo.
|
||||
*/
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore } from '../stores/editor.svelte'
|
||||
import type { Attribute, AttrID, EntityID, GroupID } from '@/vice/libs/types'
|
||||
import GroupMetaForm from './GroupMetaForm.svelte'
|
||||
import AttributeDetail from './AttributeDetail.svelte'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
const langs = $derived(editor.collection.langs)
|
||||
const entity = $derived(editor.activeEntity!)
|
||||
const group = $derived(editor.activeGroup!)
|
||||
|
||||
function ling(l: Record<string, string> | undefined, lang: string): string {
|
||||
if (!l) return ''
|
||||
return l[lang] ?? l['es'] ?? Object.values(l)[0] ?? ''
|
||||
}
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
required: 'req',
|
||||
optional: 'opt',
|
||||
fixed: 'fix',
|
||||
literal: 'lit',
|
||||
}
|
||||
|
||||
const typeBadgeClass: Record<string, string> = {
|
||||
required: 'bg-indigo-900/50 text-indigo-400',
|
||||
optional: 'bg-amber-900/50 text-amber-400',
|
||||
fixed: 'bg-gray-800 text-gray-500',
|
||||
literal: 'bg-teal-900/50 text-teal-400',
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full">
|
||||
|
||||
<!-- Lista -->
|
||||
<div class="w-72 shrink-0 border-r border-gray-800 flex flex-col">
|
||||
|
||||
<!-- Meta del grupo -->
|
||||
<div class="px-4 py-3 border-b border-gray-800 shrink-0">
|
||||
<GroupMetaForm />
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
Atributos ({group.attributes.length})
|
||||
</span>
|
||||
<button
|
||||
onclick={() => editor.addAttribute(entity.id, group.id)}
|
||||
class="text-gray-500 hover:text-indigo-400 transition-colors text-sm
|
||||
w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-500/10"
|
||||
title="Añadir atributo"
|
||||
>+</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-1">
|
||||
{#if group.attributes.length === 0}
|
||||
<div class="px-4 py-8 text-center text-gray-600 text-xs">
|
||||
<p>Sin atributos</p>
|
||||
<p class="mt-1 text-gray-700">Suelo, color, material...</p>
|
||||
<button
|
||||
onclick={() => editor.addAttribute(entity.id, group.id)}
|
||||
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 atributo</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#each group.attributes as attr (attr.id)}
|
||||
{@const isActive = editor.activeAttrId === attr.id}
|
||||
{@const hasError = editor.validation.errors.some(e => e.path.includes(attr.id))}
|
||||
{@const hasWarn = editor.validation.warnings.some(e => e.path.includes(attr.id))}
|
||||
{@const name = ling(attr.name as Record<string,string>, langs[0])}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => editor.setActiveAttribute(attr.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && editor.setActiveAttribute(attr.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="text-xs px-1.5 py-0.5 rounded shrink-0
|
||||
{typeBadgeClass[attr.type] ?? 'bg-gray-800 text-gray-500'}">
|
||||
{typeLabel[attr.type] ?? attr.type}
|
||||
</span>
|
||||
|
||||
{#if hasError}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-400 shrink-0"></span>
|
||||
{:else if hasWarn}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0"></span>
|
||||
{/if}
|
||||
|
||||
<span class="flex-1 truncate">
|
||||
{#if name}{name}{:else}<span class="italic text-gray-600">Sin nombre</span>{/if}
|
||||
</span>
|
||||
|
||||
{#if attr.type === 'required' || attr.type === 'optional'}
|
||||
<span class="text-xs text-gray-600 shrink-0">
|
||||
{attr.options?.length ?? 0}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); editor.deleteAttribute(entity.id, group.id, attr.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 -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
{#if editor.activeAttribute}
|
||||
<AttributeDetail />
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
|
||||
Selecciona un atributo para editarlo
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -1,339 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import {ling, type SupportedLocale} from '@/ling/lib'
|
||||
import type { EditorStore } from '../stores/editor.svelte.ts'
|
||||
import type {Attribute, LiteralAttribute} from "@/vice/libs";
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
const VALUE_KINDS = ['string', 'number', 'boolean', 'currency'] as const
|
||||
|
||||
function asLiteral(attr: Attribute): LiteralAttribute {
|
||||
return attr as LiteralAttribute
|
||||
}
|
||||
|
||||
function getLiteralName(attr: Attribute, lang: SupportedLocale): string {
|
||||
return (attr.name as Record<string, string>)?.[lang] ?? ''
|
||||
}
|
||||
|
||||
function patchLiteralName(attr: Attribute, lang: SupportedLocale, value: string) {
|
||||
const current = attr.name as Record<string, string> ?? {}
|
||||
editor.updateCatalogAttribute(attr.id, { name: { ...current, [lang]: value } })
|
||||
}
|
||||
|
||||
const col = $derived(editor.collection)
|
||||
const name = $derived(col.name as any)
|
||||
const desc = $derived((col.description ?? {}) as any)
|
||||
|
||||
const ALL_LANGS: SupportedLocale[] = ['es', 'en', 'de', 'fr', 'it', 'pt', 'ca', 'eu', 'gl']
|
||||
|
||||
function patch(key: string, value: unknown) {
|
||||
editor.updateCollectionMeta({ [key]: value } as any)
|
||||
}
|
||||
|
||||
function patchI18n(field: 'name' | 'description', lang: SupportedLocale, value: string) {
|
||||
const current = col[field] as any
|
||||
editor.updateCollectionMeta({ [field]: { ...current, [lang]: value } } as any)
|
||||
}
|
||||
|
||||
function toggleLang(lang: SupportedLocale) {
|
||||
const current = col.langs
|
||||
if (lang === 'es') return // es es obligatorio
|
||||
const next = current.includes(lang)
|
||||
? current.filter(l => l !== lang)
|
||||
: [...current, lang]
|
||||
patch('langs', next)
|
||||
}
|
||||
|
||||
// Stats
|
||||
const stats = $derived({
|
||||
entities: Object.keys(col.entities).length,
|
||||
groups: Object.values(col.entities).reduce((acc, e) => acc + Object.keys(e.groups).length, 0),
|
||||
attributes: Object.values(col.entities).reduce((acc, e) =>
|
||||
acc + e.attributes.length + Object.values(e.groups).reduce((a, g) => a + g.attributes.length, 0), 0
|
||||
),
|
||||
options: Object.keys(col.options).length,
|
||||
})
|
||||
|
||||
// Import JSON
|
||||
let importText = $state('')
|
||||
let importError = $state('')
|
||||
let showImport = $state(false)
|
||||
|
||||
function handleImport() {
|
||||
try {
|
||||
editor.importFromJson(importText)
|
||||
showImport = false
|
||||
importText = ''
|
||||
importError = ''
|
||||
} catch {
|
||||
importError = 'JSON inválido'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-2xl flex flex-col gap-8">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-gray-200">Configuración de la colección</h2>
|
||||
<span class="text-xs text-gray-600 font-mono">{col.id}</span>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
{#each [
|
||||
{ label: 'Entidades', value: stats.entities },
|
||||
{ label: 'Grupos', value: stats.groups },
|
||||
{ label: 'Atributos', value: stats.attributes },
|
||||
{ label: 'Opciones', value: stats.options },
|
||||
] as stat}
|
||||
<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">{stat.value}</span>
|
||||
<span class="text-xs text-gray-600">{stat.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Identificación -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Identificación</h3>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">
|
||||
Código
|
||||
<span class="text-gray-600 font-normal ml-1">— identificador único sin espacios</span>
|
||||
{#if !col.code}<span class="text-red-400 ml-1">*</span>{/if}
|
||||
</label>
|
||||
<input type="text" value={col.code}
|
||||
oninput={(e) => patch('code', e.currentTarget.value)}
|
||||
placeholder="viviendas_vice"
|
||||
class="field-input font-mono text-xs" />
|
||||
</div>
|
||||
|
||||
<!-- Nombre por idioma -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">
|
||||
Nombre
|
||||
{#if !ling.ts(col.name)}<span class="text-red-400 ml-1">*</span>{/if}
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each col.langs as lang}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-600 w-5 shrink-0">{lang}</span>
|
||||
<input type="text" value={name[lang] ?? ''}
|
||||
oninput={(e) => patchI18n('name', lang, e.currentTarget.value)}
|
||||
placeholder={lang === 'es' ? 'Viviendas VICE' : ''}
|
||||
class="field-input flex-1" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Descripción por idioma -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">Descripción</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each col.langs as lang}
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-xs text-gray-600 w-5 shrink-0 pt-2">{lang}</span>
|
||||
<textarea
|
||||
oninput={(e) => patchI18n('description', lang, e.currentTarget.value)}
|
||||
rows="2" class="field-input resize-none flex-1"
|
||||
>{desc[lang] ?? ''}</textarea>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Idiomas -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Idiomas soportados</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each ALL_LANGS as lang}
|
||||
{@const active = col.langs.includes(lang)}
|
||||
{@const required = lang === 'es'}
|
||||
<button
|
||||
onclick={() => toggleLang(lang)}
|
||||
disabled={required}
|
||||
class="px-3 py-1 text-xs rounded border transition-colors
|
||||
{active
|
||||
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
|
||||
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}
|
||||
{required ? 'opacity-60 cursor-not-allowed' : ''}"
|
||||
title={required ? 'Español es obligatorio' : ''}
|
||||
>
|
||||
{lang}
|
||||
{#if required}<span class="ml-1 text-gray-600">*</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-700">* Español es obligatorio y no puede desactivarse</p>
|
||||
</section>
|
||||
|
||||
<!-- Atributos del catálogo -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Atributos de contexto
|
||||
<span class="text-gray-700 font-normal ml-1">({col.attributes.length})</span>
|
||||
</h3>
|
||||
<button
|
||||
onclick={() => editor.addCatalogAttribute()}
|
||||
class="text-xs px-2 py-1 rounded border border-dashed border-gray-700
|
||||
text-gray-500 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
|
||||
>+ Añadir</button>
|
||||
</div>
|
||||
|
||||
{#if col.attributes.length === 0}
|
||||
<p class="text-xs text-gray-700 italic">
|
||||
Sin atributos — añade variables de contexto como país, normativa fiscal, año...
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each col.attributes as attr}
|
||||
{@const lit = asLiteral(attr)}
|
||||
<div class="rounded-md border border-gray-800 bg-gray-900/50 p-3 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Código -->
|
||||
<input type="text" value={attr.code}
|
||||
oninput={(e) => editor.updateCatalogAttribute(attr.id, { code: e.currentTarget.value })}
|
||||
placeholder="country"
|
||||
class="field-input font-mono text-xs w-32" />
|
||||
|
||||
<!-- Kind -->
|
||||
<select
|
||||
onchange={(e) => editor.updateCatalogAttribute(attr.id, { kind: e.currentTarget.value })}
|
||||
class="field-input text-xs w-28"
|
||||
>
|
||||
{#each VALUE_KINDS as k}
|
||||
<option value={k} selected={lit.kind === k}>{k}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- Valor -->
|
||||
{#if lit.kind === 'boolean'}
|
||||
<button
|
||||
onclick={() => editor.updateCatalogAttribute(attr.id, { value: !lit.value })}
|
||||
class="relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent
|
||||
transition-colors cursor-pointer
|
||||
{lit.value ? 'bg-indigo-600' : 'bg-gray-700'}"
|
||||
role="switch" aria-checked={!!lit.value}
|
||||
>
|
||||
<span class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow
|
||||
transition-transform
|
||||
{lit.value ? 'translate-x-4' : 'translate-x-0'}"></span>
|
||||
</button>
|
||||
{:else}
|
||||
<input
|
||||
type={lit.kind === 'number' ? 'number' : 'text'}
|
||||
value={lit.value ?? ''}
|
||||
oninput={(e) => editor.updateCatalogAttribute(attr.id, {
|
||||
value: lit.kind === 'number'
|
||||
? Number(e.currentTarget.value)
|
||||
: e.currentTarget.value
|
||||
})}
|
||||
class="field-input text-xs w-24"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={() => editor.deleteCatalogAttribute(attr.id)}
|
||||
class="text-gray-600 hover:text-red-400 transition-colors text-xs shrink-0"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Nombre por idioma -->
|
||||
<div class="grid grid-cols-2 gap-2 pl-1">
|
||||
{#each col.langs as lang}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-600 w-5 shrink-0">{lang}</span>
|
||||
<input type="text" value={getLiteralName(attr, lang)}
|
||||
oninput={(e) => patchLiteralName(attr, lang, e.currentTarget.value)}
|
||||
placeholder={lang === 'es' ? 'País' : ''}
|
||||
class="field-input text-xs flex-1" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Base Path -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Rutas de imagen</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">
|
||||
Base Path
|
||||
<span class="text-gray-600 font-normal ml-1">— ruta raíz para templates de imagen</span>
|
||||
</label>
|
||||
<input type="text" value={col.basePath ?? ''}
|
||||
oninput={(e) => patch('basePath', e.currentTarget.value)}
|
||||
placeholder="/renders/"
|
||||
class="field-input font-mono text-xs" />
|
||||
</div>
|
||||
<div class="rounded-md bg-gray-900/50 border border-gray-800 p-3 text-xs text-gray-500">
|
||||
Los templates de imagen se resuelven como
|
||||
<code class="text-indigo-400 ml-1">basePath + template</code>.
|
||||
Cada grupo puede sobreescribir este valor.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Errores de validación -->
|
||||
{#if editor.validation.errors.filter(e => e.path.startsWith('collection')).length > 0}
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="text-xs font-semibold text-red-400 uppercase tracking-wider">Errores</h3>
|
||||
{#each editor.validation.errors.filter(e => e.path.startsWith('collection')) as error}
|
||||
<div class="flex flex-col gap-0.5 p-2 rounded bg-red-500/10 border border-red-500/20">
|
||||
<p class="text-xs text-red-300">{error.message}</p>
|
||||
<p class="text-xs text-red-400/50 font-mono">{error.path}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Importar / Resetear -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Importar / Resetear</h3>
|
||||
<div class="flex gap-2">
|
||||
<button onclick={() => showImport = !showImport}
|
||||
class="text-xs px-3 py-1.5 rounded border border-gray-700 text-gray-400
|
||||
hover:text-indigo-400 hover:border-indigo-700 transition-colors">
|
||||
Importar JSON
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { if (confirm('¿Resetear la colección? Se perderán todos los cambios.')) editor.reset() }}
|
||||
class="text-xs px-3 py-1.5 rounded border border-gray-700 text-gray-500
|
||||
hover:text-red-400 hover:border-red-800 transition-colors">
|
||||
Resetear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showImport}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea bind:value={importText}
|
||||
placeholder="Pega aquí el JSON de la colección..."
|
||||
rows="8" class="field-input resize-none font-mono text-xs"></textarea>
|
||||
{#if importError}
|
||||
<p class="text-xs text-red-400">{importError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<button onclick={handleImport} disabled={!importText.trim()}
|
||||
class="text-xs px-3 py-1.5 rounded bg-indigo-600 hover:bg-indigo-500
|
||||
text-white transition-colors disabled:opacity-40">
|
||||
Importar
|
||||
</button>
|
||||
<button onclick={() => { showImport = false; importText = ''; importError = '' }}
|
||||
class="text-xs px-3 py-1.5 rounded border border-gray-700 text-gray-400
|
||||
hover:text-gray-200 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@ -1,431 +0,0 @@
|
||||
<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/lib/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>
|
||||
@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ============================================================================
|
||||
* ENTITIES PANEL
|
||||
* ============================================================================
|
||||
*
|
||||
* Layout de tres columnas:
|
||||
* EntityList | GroupList | AttributeList / Detail
|
||||
*/
|
||||
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore } from '../stores//editor.svelte'
|
||||
import EntityList from './EntityList.svelte'
|
||||
import GroupList from './GroupList.svelte'
|
||||
import AttributeList from './AttributeList.svelte'
|
||||
import AttributeDetail from './AttributeDetail.svelte'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
</script>
|
||||
|
||||
<div class="flex h-full">
|
||||
|
||||
<!-- Columna 1 — Entidades -->
|
||||
<div class="w-56 shrink-0 border-r border-gray-800 flex flex-col">
|
||||
<EntityList />
|
||||
</div>
|
||||
|
||||
<!-- Columna 2 — Grupos (solo si hay entidad activa) -->
|
||||
{#if editor.activeEntity}
|
||||
<div class="w-56 shrink-0 border-r border-gray-800 flex flex-col">
|
||||
<GroupList />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Columna 3 — Atributos / detalle -->
|
||||
<div class="flex-1 overflow-hidden flex flex-col">
|
||||
{#if editor.activeGroup}
|
||||
<AttributeList />
|
||||
{:else if editor.activeAttrId && editor.activeEntity}
|
||||
<!-- Atributo de entidad seleccionado desde GroupList -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<AttributeDetail />
|
||||
</div>
|
||||
{:else if editor.activeEntity}
|
||||
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
|
||||
Selecciona un grupo o un atributo de entidad
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
|
||||
Selecciona o crea una entidad
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -1,160 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ENTITY ATTRIBUTE LIST
|
||||
*
|
||||
* Muestra y edita los atributos en dos niveles:
|
||||
* 1. Entidad (entity.attributes)
|
||||
* 2. Catálogo (collection.attributes)
|
||||
*
|
||||
* Se muestra en col-3 cuando hay entidad activa pero no grupo activo.
|
||||
*/
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore } from '../stores/editor.svelte'
|
||||
import type { Attribute, AttrID } from '@/vice/libs/types'
|
||||
import AttributeDetail from './AttributeDetail.svelte'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
const langs = $derived(editor.collection.langs)
|
||||
const entity = $derived(editor.activeEntity!)
|
||||
|
||||
function ling(l: Record<string, string> | undefined, lang: string): string {
|
||||
if (!l) return ''
|
||||
return l[lang] ?? l['es'] ?? Object.values(l)[0] ?? ''
|
||||
}
|
||||
|
||||
type Section = {
|
||||
key: string
|
||||
label: string
|
||||
color: string
|
||||
attrs: Attribute[]
|
||||
onAdd: () => void
|
||||
onDelete: (id: AttrID) => void
|
||||
}
|
||||
|
||||
const sections = $derived((() => {
|
||||
const e = entity // track entity reactively
|
||||
return [
|
||||
{
|
||||
key: 'entity',
|
||||
label: 'Entidad',
|
||||
color: 'text-amber-500',
|
||||
attrs: e.attributes,
|
||||
onAdd: () => editor.addAttribute(e.id, undefined),
|
||||
onDelete: (id: AttrID) => editor.deleteAttribute(e.id, undefined, id),
|
||||
},
|
||||
{
|
||||
key: 'catalog',
|
||||
label: 'Catálogo',
|
||||
color: 'text-gray-500',
|
||||
attrs: editor.collection.attributes,
|
||||
onAdd: () => editor.addCatalogAttribute(),
|
||||
onDelete: (id: AttrID) => editor.deleteCatalogAttribute(id),
|
||||
},
|
||||
] satisfies Section[]
|
||||
})())
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
required: 'req', optional: 'opt', fixed: 'fix', literal: 'lit',
|
||||
}
|
||||
const typeBadgeClass: Record<string, string> = {
|
||||
required: 'bg-indigo-900/50 text-indigo-400',
|
||||
optional: 'bg-amber-900/50 text-amber-400',
|
||||
fixed: 'bg-gray-800 text-gray-500',
|
||||
literal: 'bg-teal-900/50 text-teal-400',
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full">
|
||||
|
||||
<!-- Lista -->
|
||||
<div class="w-72 shrink-0 border-r border-gray-800 flex flex-col overflow-hidden">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
|
||||
{#each sections as section (section.key)}
|
||||
|
||||
<!-- Cabecera de sección -->
|
||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800
|
||||
sticky top-0 bg-gray-950 z-10">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider {section.color}">
|
||||
{section.label}
|
||||
<span class="text-gray-700 font-normal ml-1">({section.attrs.length})</span>
|
||||
</span>
|
||||
<button
|
||||
onclick={section.onAdd}
|
||||
class="text-gray-600 hover:text-indigo-400 transition-colors text-sm
|
||||
w-5 h-5 flex items-center justify-center rounded hover:bg-indigo-500/10"
|
||||
title="Añadir atributo"
|
||||
>+</button>
|
||||
</div>
|
||||
|
||||
<!-- Atributos -->
|
||||
{#if section.attrs.length === 0}
|
||||
<div class="px-4 py-5 text-center text-gray-700 text-xs italic">
|
||||
Sin atributos
|
||||
</div>
|
||||
{:else}
|
||||
{#each section.attrs as attr (attr.id)}
|
||||
{@const isActive = editor.activeAttrId === attr.id}
|
||||
{@const hasError = editor.validation.errors.some(e => e.path.includes(attr.id))}
|
||||
{@const hasWarn = editor.validation.warnings.some(e => e.path.includes(attr.id))}
|
||||
{@const name = ling(attr.name as Record<string,string>, langs[0])}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => editor.setActiveAttribute(attr.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && editor.setActiveAttribute(attr.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="text-xs px-1.5 py-0.5 rounded shrink-0
|
||||
{typeBadgeClass[attr.type] ?? 'bg-gray-800 text-gray-500'}">
|
||||
{typeLabel[attr.type] ?? attr.type}
|
||||
</span>
|
||||
|
||||
{#if hasError}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-400 shrink-0"></span>
|
||||
{:else if hasWarn}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0"></span>
|
||||
{/if}
|
||||
|
||||
<span class="flex-1 truncate">
|
||||
{#if name}{name}{:else}<span class="italic text-gray-600">Sin nombre</span>{/if}
|
||||
</span>
|
||||
|
||||
{#if attr.type === 'required' || attr.type === 'optional'}
|
||||
<span class="text-xs text-gray-600 shrink-0">
|
||||
{(attr as any).options?.length ?? 0}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); section.onDelete(attr.id) }}
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
|
||||
transition-opacity text-xs"
|
||||
>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detalle -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
{#if editor.activeAttribute}
|
||||
<AttributeDetail />
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
|
||||
Selecciona un atributo para editarlo
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -1,89 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore } from '../../editor.svelte'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
const entities = $derived(Object.values(editor.collection.entities))
|
||||
</script>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800">
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Entidades ({entities.length})
|
||||
</span>
|
||||
<button
|
||||
onclick={() => editor.addEntity()}
|
||||
class="text-gray-500 hover:text-indigo-400 transition-colors text-sm
|
||||
w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-500/10"
|
||||
title="Nueva entidad"
|
||||
>+</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if entities.length === 0}
|
||||
<div class="px-4 py-8 text-center text-gray-600 text-xs">
|
||||
<p>Sin entidades</p>
|
||||
<p class="mt-1 text-gray-700">
|
||||
Una entidad es un producto configurable — apartamento, coche, habitación...
|
||||
</p>
|
||||
<button
|
||||
onclick={() => editor.addEntity()}
|
||||
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 entidad
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="py-1">
|
||||
{#each entities as entity (entity.id)}
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => editor.setActiveEntity(entity.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && editor.setActiveEntity(entity.id)}
|
||||
class="
|
||||
w-full text-left px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer
|
||||
transition-colors group
|
||||
{editor.activeEntityId === entity.id
|
||||
? 'bg-indigo-600/20 text-indigo-300'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'}
|
||||
"
|
||||
>
|
||||
<!-- Indicador de error -->
|
||||
{#if editor.validation.errors.some(e => e.path.startsWith(`entities.${entity.id}`))}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-400 shrink-0"></span>
|
||||
{:else if editor.validation.warnings.some(e => e.path.startsWith(`entities.${entity.id}`))}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0"></span>
|
||||
{:else}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-transparent shrink-0"></span>
|
||||
{/if}
|
||||
|
||||
<span class="flex-1 truncate">
|
||||
{#if entity.name.es}
|
||||
{entity.name.es}
|
||||
{:else}
|
||||
<span class="italic text-gray-600">Sin nombre</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<!-- Nº grupos -->
|
||||
<span class="text-xs text-gray-600 shrink-0">
|
||||
{Object.keys(entity.groups).length}g
|
||||
</span>
|
||||
|
||||
<!-- Botón eliminar -->
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); editor.deleteEntity(entity.id) }}
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
|
||||
transition-opacity text-xs px-1"
|
||||
title="Eliminar entidad"
|
||||
>✕</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore } from '../../editor.svelte'
|
||||
import type { SupportedLocale } from '../../types'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
const entity = $derived(editor.activeEntity!)
|
||||
const langs = $derived(editor.collection.langs)
|
||||
|
||||
function patch(key: string, value: unknown) {
|
||||
editor.updateEntity(entity.id, { [key]: value })
|
||||
}
|
||||
|
||||
function patchI18n(field: 'name' | 'description', lang: SupportedLocale, value: string) {
|
||||
const current = entity[field]
|
||||
editor.updateEntity(entity.id, { [field]: { ...current, [lang]: value } })
|
||||
if (field === 'name' && lang === 'es' && !entity.code) {
|
||||
editor.updateEntity(entity.id, {
|
||||
code: value.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<!-- Código -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">
|
||||
Código
|
||||
{#if !entity.code}
|
||||
<span class="text-red-400 ml-1">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={entity.code}
|
||||
oninput={(e) => patch('code', e.currentTarget.value)}
|
||||
placeholder="apartamento"
|
||||
class="field-input font-mono text-xs {!entity.code ? 'border-red-800' : ''}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Nombre por lang -->
|
||||
{#each langs as lang (lang)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">
|
||||
Nombre ({lang})
|
||||
{#if lang === 'es' && !entity.name.es}
|
||||
<span class="text-red-400 ml-1">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={entity.name[lang] ?? ''}
|
||||
oninput={(e) => patchI18n('name', lang, e.currentTarget.value)}
|
||||
placeholder={lang === 'es' ? 'Apartamento' : 'Apartment'}
|
||||
class="field-input {lang === 'es' && !entity.name.es ? 'border-red-800' : ''}"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
</div>
|
||||
@ -1,187 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore } from '../stores/editor.svelte'
|
||||
import type { GroupID } from '@/vice/libs/types'
|
||||
import EntityMetaForm from './EntityMetaForm.svelte'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
const langs = $derived(editor.collection.langs)
|
||||
const entity = $derived(editor.activeEntity!)
|
||||
|
||||
function ling(l: Record<string, string> | undefined): string {
|
||||
if (!l) return ''
|
||||
return l[langs[0]] ?? l['es'] ?? Object.values(l)[0] ?? ''
|
||||
}
|
||||
|
||||
const groups = $derived(
|
||||
(entity.groupOrder ?? (Object.keys(entity.groups) as GroupID[]))
|
||||
.map(id => entity.groups[id])
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
let showMeta = $state(false)
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
required: 'req', optional: 'opt', fixed: 'fix', literal: 'lit',
|
||||
}
|
||||
const typeBadgeClass: Record<string, string> = {
|
||||
required: 'bg-indigo-900/50 text-indigo-400',
|
||||
optional: 'bg-amber-900/50 text-amber-400',
|
||||
fixed: 'bg-gray-800 text-gray-500',
|
||||
literal: 'bg-teal-900/50 text-teal-400',
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Nombre de la entidad activa + toggle meta -->
|
||||
<div class="px-4 py-3 border-b border-gray-800 shrink-0">
|
||||
<button
|
||||
onclick={() => showMeta = !showMeta}
|
||||
class="w-full text-left flex items-center justify-between group"
|
||||
>
|
||||
<span class="text-xs font-semibold text-gray-300 uppercase tracking-wider truncate">
|
||||
{ling(entity.name as Record<string,string>) || 'Entidad sin nombre'}
|
||||
</span>
|
||||
<span class="text-gray-600 text-xs group-hover:text-gray-400 transition-colors">
|
||||
{showMeta ? '▲' : '▼'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if showMeta}
|
||||
<div class="mt-3">
|
||||
<EntityMetaForm />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
|
||||
<!-- ── Grupos ─────────────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-800 sticky top-0 bg-gray-900 z-10">
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Grupos <span class="text-gray-700 font-normal">({groups.length})</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => editor.addGroup(entity.id, 'fixed')}
|
||||
class="text-gray-500 hover:text-indigo-400 transition-colors text-xs px-1.5 py-0.5
|
||||
rounded border border-gray-700 hover:border-indigo-700"
|
||||
title="Grupo fijo"
|
||||
>fx</button>
|
||||
<button
|
||||
onclick={() => editor.addGroup(entity.id, 'optional')}
|
||||
class="text-gray-500 hover:text-amber-400 transition-colors text-xs px-1.5 py-0.5
|
||||
rounded border border-gray-700 hover:border-amber-700"
|
||||
title="Grupo opcional"
|
||||
>opt</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if groups.length === 0}
|
||||
<div class="px-4 py-6 text-center text-gray-700 text-xs italic">
|
||||
Sin grupos
|
||||
<button
|
||||
onclick={() => editor.addGroup(entity.id, 'fixed')}
|
||||
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 grupo</button>
|
||||
</div>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each groups as group (group.id)}
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => editor.setActiveGroup(group.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && editor.setActiveGroup(group.id)}
|
||||
class="
|
||||
w-full text-left px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer
|
||||
transition-colors group
|
||||
{editor.activeGroupId === group.id
|
||||
? 'bg-indigo-600/20 text-indigo-300'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'}
|
||||
"
|
||||
>
|
||||
<span class="text-xs text-gray-600 shrink-0 w-3">
|
||||
{group.kind === 'fixed' ? '◉' : '◈'}
|
||||
</span>
|
||||
|
||||
{#if editor.validation.errors.some(e => e.path.includes(group.id))}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-400 shrink-0"></span>
|
||||
{:else if editor.validation.warnings.some(e => e.path.includes(group.id))}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0"></span>
|
||||
{/if}
|
||||
|
||||
<span class="flex-1 truncate">
|
||||
{#if ling(group.name as Record<string,string>)}
|
||||
{ling(group.name as Record<string,string>)}
|
||||
{:else}
|
||||
<span class="italic text-gray-600">Sin nombre</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="text-xs text-gray-600 shrink-0">{group.attributes.length}</span>
|
||||
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); editor.deleteGroup(entity.id, group.id) }}
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
|
||||
transition-opacity text-xs px-1"
|
||||
>✕</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- ── Atributos de entidad ───────────────────────────────────────────── -->
|
||||
<div class="flex items-center justify-between px-4 py-2 border-t border-b border-gray-800 sticky top-0 bg-gray-900 z-10 mt-1">
|
||||
<span class="text-xs font-semibold text-amber-600 uppercase tracking-wider">
|
||||
Attrs entidad <span class="text-gray-700 font-normal">({entity.attributes.length})</span>
|
||||
</span>
|
||||
<button
|
||||
onclick={() => editor.addAttribute(entity.id, undefined)}
|
||||
class="text-gray-600 hover:text-amber-400 transition-colors text-sm
|
||||
w-5 h-5 flex items-center justify-center rounded hover:bg-amber-500/10"
|
||||
title="Añadir atributo de entidad"
|
||||
>+</button>
|
||||
</div>
|
||||
|
||||
{#if entity.attributes.length === 0}
|
||||
<div class="px-4 py-4 text-center text-gray-700 text-xs italic">
|
||||
Sin atributos de entidad
|
||||
</div>
|
||||
{:else}
|
||||
{#each entity.attributes as attr (attr.id)}
|
||||
{@const isActive = editor.activeAttrId === attr.id}
|
||||
{@const name = ling(attr.name as Record<string,string>)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => editor.setActiveEntityAttribute(attr.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && editor.setActiveEntityAttribute(attr.id)}
|
||||
class="
|
||||
w-full text-left 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.5 py-0.5 rounded shrink-0
|
||||
{typeBadgeClass[attr.type] ?? 'bg-gray-800 text-gray-500'}">
|
||||
{typeLabel[attr.type] ?? attr.type}
|
||||
</span>
|
||||
<span class="flex-1 truncate">
|
||||
{#if name}{name}{:else}<span class="italic text-gray-600">Sin nombre</span>{/if}
|
||||
</span>
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); editor.deleteAttribute(entity.id, undefined, attr.id) }}
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
|
||||
transition-opacity text-xs"
|
||||
>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
@ -1,79 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore } from '../../editor.svelte'
|
||||
import type { SupportedLocale } from '../../types'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
const group = $derived(editor.activeGroup!)
|
||||
const entity = $derived(editor.activeEntity!)
|
||||
const langs = $derived(editor.collection.langs)
|
||||
|
||||
function patch(key: string, value: unknown) {
|
||||
editor.updateGroup(entity.id, group.id, { [key]: value })
|
||||
}
|
||||
|
||||
function patchI18n(field: 'name' | 'description', lang: SupportedLocale, value: string) {
|
||||
const current = group[field]
|
||||
editor.updateGroup(entity.id, group.id, { [field]: { ...current, [lang]: value } })
|
||||
if (field === 'name' && lang === 'es' && !group.code) {
|
||||
editor.updateGroup(entity.id, group.id, {
|
||||
code: value.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Código -->
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<label class="text-xs text-gray-500">
|
||||
Código
|
||||
{#if !group.code}
|
||||
<span class="text-red-400 ml-1">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={group.code}
|
||||
oninput={(e) => patch('code', e.currentTarget.value)}
|
||||
placeholder="salon"
|
||||
class="field-input font-mono text-xs {!group.code ? 'border-red-800' : ''}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tipo (solo lectura en visual, editable en kind) -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">Tipo</label>
|
||||
<select
|
||||
onchange={(e) => patch('kind', e.currentTarget.value)}
|
||||
class="field-input text-xs"
|
||||
>
|
||||
<option value="fixed" selected={group.kind === 'fixed'}>Fijo</option>
|
||||
<option value="optional" selected={group.kind === 'optional'}>Opcional</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nombre por lang -->
|
||||
{#each langs as lang (lang)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">
|
||||
Nombre ({lang})
|
||||
{#if lang === 'es' && !group.name.es}
|
||||
<span class="text-red-400 ml-1">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={group.name[lang] ?? ''}
|
||||
oninput={(e) => patchI18n('name', lang, e.currentTarget.value)}
|
||||
placeholder={lang === 'es' ? 'Salón' : 'Living room'}
|
||||
class="field-input {lang === 'es' && !group.name.es ? 'border-red-800' : ''}"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
</div>
|
||||
@ -1,130 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { ling } from '@/ling/lib'
|
||||
import type { EditorStore } from '../stores/editor.svelte.ts'
|
||||
import OptionDetail from './OptionDetail.svelte'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
let search = $state('')
|
||||
|
||||
const options = $derived(Object.values(editor.collection.options))
|
||||
|
||||
const filtered = $derived(
|
||||
options.filter(o =>
|
||||
!search ||
|
||||
ling.ts(o.name).toLowerCase().includes(search.toLowerCase()) ||
|
||||
o.code.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(o.tags ?? []).some(t => t.toLowerCase().includes(search.toLowerCase()))
|
||||
)
|
||||
)
|
||||
|
||||
// Agrupar por primer tag
|
||||
const grouped = $derived.by(() => {
|
||||
const groups = new Map<string, typeof filtered>()
|
||||
for (const opt of filtered) {
|
||||
const tag = (opt.tags ?? [])[0] ?? '—'
|
||||
if (!groups.has(tag)) groups.set(tag, [])
|
||||
groups.get(tag)!.push(opt)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex h-full">
|
||||
|
||||
<!-- Lista -->
|
||||
<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">
|
||||
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Opciones ({options.length})
|
||||
</span>
|
||||
<button
|
||||
onclick={() => editor.addOption()}
|
||||
class="text-gray-500 hover:text-indigo-400 transition-colors text-sm
|
||||
w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-500/10"
|
||||
>+</button>
|
||||
</div>
|
||||
|
||||
<!-- Búsqueda -->
|
||||
<div class="px-3 py-2 border-b border-gray-800">
|
||||
<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>
|
||||
|
||||
<!-- Lista -->
|
||||
<div class="flex-1 overflow-y-auto py-1">
|
||||
{#if options.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">Parquet, mármol, lacado blanco...</p>
|
||||
</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 [tag, opts]}
|
||||
<div class="px-3 py-1.5 text-xs text-gray-600 uppercase tracking-wider bg-gray-900/50 sticky top-0">
|
||||
{tag}
|
||||
</div>
|
||||
{#each opts as opt (opt.id)}
|
||||
<div
|
||||
role="button" tabindex="0"
|
||||
onclick={() => editor.setActiveOption(opt.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && editor.setActiveOption(opt.id)}
|
||||
class="px-3 py-2 flex items-center gap-2 text-sm cursor-pointer transition-colors group
|
||||
{editor.activeOptionId === opt.id
|
||||
? 'bg-indigo-600/20 text-indigo-300'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'}"
|
||||
>
|
||||
{#if editor.validation.errors.some(e => e.path.startsWith(`options.${opt.id}`))}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-400 shrink-0"></span>
|
||||
{:else if editor.validation.warnings.some(e => e.path.startsWith(`options.${opt.id}`))}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0"></span>
|
||||
{:else}
|
||||
<span class="w-1.5 h-1.5 shrink-0"></span>
|
||||
{/if}
|
||||
|
||||
<span class="flex-1 truncate text-xs">{ling.ts(opt.name) || 'Sin nombre'}</span>
|
||||
|
||||
{#if opt.pricing && !opt.pricing.dynamicExpression}
|
||||
<span class="text-xs text-gray-600 shrink-0">{opt.pricing.baseAmount}€</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); editor.deleteOption(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.activeOption}
|
||||
<OptionDetail />
|
||||
{: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 seleccionables — parquet, mármol, lacado blanco...
|
||||
Se definen una vez y se reutilizan en múltiples atributos.
|
||||
</p>
|
||||
<button
|
||||
onclick={() => editor.addOption()}
|
||||
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>
|
||||
@ -1,240 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* RULE EDITOR
|
||||
*
|
||||
* Editor de una regla individual. Embebible en cualquier contexto
|
||||
* (atributo, grupo, entidad).
|
||||
*
|
||||
* Props:
|
||||
* rule — la regla actual
|
||||
* onchange — callback cuando cambia (recibe el patch)
|
||||
* ondelete — callback para eliminar
|
||||
*/
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore } from '../stores/editor.svelte'
|
||||
import type { Rule, RuleActionType, NodeId, OptionID, Severity } from '@/vice/libs/types'
|
||||
import ConditionBuilder from './ConditionBuilder.svelte'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
let {
|
||||
rule,
|
||||
onchange,
|
||||
ondelete,
|
||||
}: {
|
||||
rule: Rule
|
||||
onchange: (patch: Partial<Rule>) => void
|
||||
ondelete: () => void
|
||||
} = $props()
|
||||
|
||||
const langs = $derived(editor.collection.langs)
|
||||
const allOptions = $derived(Object.values(editor.collection.options))
|
||||
|
||||
// Todos los nodos disponibles como targets (atributos de la entidad activa)
|
||||
const allAttrs = $derived((() => {
|
||||
const entity = editor.activeEntity
|
||||
const attrs: Array<{ id: string; label: string }> = []
|
||||
|
||||
// Atributos de catálogo
|
||||
for (const a of editor.collection.attributes) {
|
||||
const name = (a.name as Record<string, string>)?.[langs[0]] || a.code || a.id
|
||||
attrs.push({ id: a.id, label: `catálogo / ${name}` })
|
||||
}
|
||||
|
||||
if (!entity) return attrs
|
||||
|
||||
// Atributos de entidad
|
||||
for (const a of entity.attributes) {
|
||||
const name = (a.name as Record<string, string>)?.[langs[0]] || a.code || a.id
|
||||
attrs.push({ id: a.id, label: `entidad / ${name}` })
|
||||
}
|
||||
|
||||
// Atributos de todos los grupos
|
||||
for (const grp of Object.values(entity.groups)) {
|
||||
for (const a of grp.attributes) {
|
||||
const name = (a.name as Record<string, string>)?.[langs[0]] || a.code || a.id
|
||||
const grpName = (grp.name as Record<string, string>)?.[langs[0]] || grp.code
|
||||
attrs.push({ id: a.id, label: `${grpName} / ${name}` })
|
||||
}
|
||||
}
|
||||
|
||||
return attrs
|
||||
})())
|
||||
|
||||
const allGroups = $derived((() => {
|
||||
const entity = editor.activeEntity
|
||||
if (!entity) return []
|
||||
return Object.values(entity.groups).map(g => ({
|
||||
id: g.id,
|
||||
label: (g.name as Record<string, string>)?.[langs[0]] || g.code || g.id,
|
||||
}))
|
||||
})())
|
||||
|
||||
// Action types que aplican a attrs vs grupos
|
||||
const attrActions: RuleActionType[] = ['allow', 'forbid', 'require', 'suggest']
|
||||
const groupActions: RuleActionType[] = ['activate', 'deactivate']
|
||||
|
||||
function ling(l: Record<string, string> | undefined, lang: string): string {
|
||||
if (!l) return ''
|
||||
return l[lang] ?? l['es'] ?? Object.values(l)[0] ?? ''
|
||||
}
|
||||
|
||||
function patchAction(key: string, value: unknown) {
|
||||
onchange({ action: { ...rule.action, [key]: value } })
|
||||
}
|
||||
|
||||
function patchMessage(lang: string, value: string) {
|
||||
const current = (rule.message as Record<string, string>) ?? {}
|
||||
onchange({ message: { ...current, [lang]: value } as any })
|
||||
}
|
||||
|
||||
function toggleValue(optionId: OptionID) {
|
||||
const current = rule.action.values ?? []
|
||||
const next = current.includes(optionId)
|
||||
? current.filter(v => v !== optionId)
|
||||
: [...current, optionId]
|
||||
patchAction('values', next)
|
||||
}
|
||||
|
||||
const needsValues = $derived(
|
||||
rule.action.type === 'allow' ||
|
||||
rule.action.type === 'forbid' ||
|
||||
rule.action.type === 'suggest'
|
||||
)
|
||||
|
||||
// Opciones disponibles en el target attr (si aplica)
|
||||
const targetAttrOptions = $derived((() => {
|
||||
if (!needsValues) return []
|
||||
const entity = editor.activeEntity
|
||||
// Buscar el atributo target en todos los niveles
|
||||
const allAttrsList = [
|
||||
...editor.collection.attributes,
|
||||
...(entity ? entity.attributes : []),
|
||||
...(entity ? Object.values(entity.groups).flatMap(g => g.attributes) : []),
|
||||
]
|
||||
const target = allAttrsList.find(a => a.id === rule.action.target)
|
||||
if (target && (target.type === 'required' || target.type === 'optional')) {
|
||||
return (target as any).options
|
||||
.map((ao: any) => editor.collection.options[ao.optionId])
|
||||
.filter(Boolean)
|
||||
}
|
||||
return allOptions
|
||||
})())
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border border-gray-800 bg-gray-900/60 flex flex-col gap-0 overflow-hidden">
|
||||
|
||||
<!-- Header de la regla -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-800 bg-gray-900">
|
||||
|
||||
<!-- Severity -->
|
||||
<select
|
||||
value={rule.severity}
|
||||
onchange={(e) => onchange({ severity: e.currentTarget.value as Severity })}
|
||||
class="text-xs rounded border border-gray-700 bg-gray-800 px-1.5 py-1
|
||||
{rule.severity === 'error' ? 'text-red-400' :
|
||||
rule.severity === 'warning' ? 'text-amber-400' : 'text-blue-400'}"
|
||||
>
|
||||
<option value="error">error</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="info">info</option>
|
||||
</select>
|
||||
|
||||
<!-- Action type -->
|
||||
<select
|
||||
value={rule.action.type}
|
||||
onchange={(e) => patchAction('type', e.currentTarget.value)}
|
||||
class="text-xs rounded border border-gray-700 bg-gray-800 text-gray-300 px-1.5 py-1 flex-1"
|
||||
>
|
||||
<optgroup label="Atributo">
|
||||
{#each attrActions as t}
|
||||
<option value={t}>{t}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
<optgroup label="Grupo">
|
||||
{#each groupActions as t}
|
||||
<option value={t}>{t}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
<!-- Target -->
|
||||
{#if groupActions.includes(rule.action.type)}
|
||||
<select
|
||||
value={rule.action.target ?? ''}
|
||||
onchange={(e) => patchAction('target', e.currentTarget.value)}
|
||||
class="text-xs rounded border border-gray-700 bg-gray-800 text-gray-300 px-1.5 py-1 flex-1"
|
||||
>
|
||||
<option value="">— grupo —</option>
|
||||
{#each allGroups as g}
|
||||
<option value={g.id}>{g.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<select
|
||||
value={rule.action.target ?? ''}
|
||||
onchange={(e) => patchAction('target', e.currentTarget.value)}
|
||||
class="text-xs rounded border border-gray-700 bg-gray-800 text-gray-300 px-1.5 py-1 flex-1"
|
||||
>
|
||||
<option value="">— atributo —</option>
|
||||
{#each allAttrs as a}
|
||||
<option value={a.id}>{a.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
<!-- Eliminar -->
|
||||
<button
|
||||
onclick={ondelete}
|
||||
class="text-gray-600 hover:text-red-400 transition-colors text-xs px-1"
|
||||
title="Eliminar regla"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Valores (allow/forbid/suggest) -->
|
||||
{#if needsValues && targetAttrOptions.length > 0}
|
||||
<div class="px-3 py-2 border-b border-gray-800 flex flex-wrap gap-1">
|
||||
{#each targetAttrOptions as opt (opt.id)}
|
||||
{@const active = (rule.action.values ?? []).includes(opt.id)}
|
||||
<button
|
||||
onclick={() => toggleValue(opt.id)}
|
||||
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'}"
|
||||
>
|
||||
{ling(opt.name as Record<string, string>, langs[0]) || opt.code}
|
||||
</button>
|
||||
{/each}
|
||||
{#if needsValues && targetAttrOptions.length === 0}
|
||||
<span class="text-xs text-gray-600 italic">Selecciona un atributo target primero</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Condition (ConditionBuilder) -->
|
||||
<div class="px-3 py-2 border-b border-gray-800">
|
||||
<ConditionBuilder
|
||||
condition={rule.condition}
|
||||
{allAttrs}
|
||||
onchange={(jl) => onchange({ condition: jl })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje -->
|
||||
<div class="px-3 py-2 flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-600 uppercase tracking-wider">Mensaje</label>
|
||||
{#each langs as lang (lang)}
|
||||
<input
|
||||
type="text"
|
||||
value={(rule.message as Record<string, string>)?.[lang] ?? ''}
|
||||
oninput={(e) => patchMessage(lang, e.currentTarget.value)}
|
||||
placeholder="Esta opción requiere..."
|
||||
class="field-input text-xs"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -1,78 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* RULE LIST
|
||||
*
|
||||
* Lista de reglas embebible en AttributeDetail, GroupList, etc.
|
||||
*
|
||||
* Props:
|
||||
* rules — array de reglas actuales
|
||||
* entityId — contexto
|
||||
* groupId — opcional
|
||||
* attrId — opcional
|
||||
*/
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore } from '../stores/editor.svelte'
|
||||
import type { Rule, RuleID, EntityID, GroupID, AttrID } from '@/vice/libs/types'
|
||||
import RuleEditor from './RuleEditor.svelte'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
let {
|
||||
rules = [],
|
||||
entityId,
|
||||
groupId,
|
||||
attrId,
|
||||
}: {
|
||||
rules?: Rule[]
|
||||
entityId: EntityID
|
||||
groupId?: GroupID
|
||||
attrId?: AttrID
|
||||
} = $props()
|
||||
|
||||
function uid(): string {
|
||||
return 'rl:' + Math.random().toString(36).slice(2, 9)
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
const rule: Rule = {
|
||||
id: uid() as RuleID,
|
||||
severity: 'warning',
|
||||
action: { type: 'forbid', target: '' as any },
|
||||
message: {} as any,
|
||||
}
|
||||
editor.addRule(entityId, groupId, attrId, rule)
|
||||
}
|
||||
|
||||
function updateRule(ruleId: string, patch: Partial<Rule>) {
|
||||
editor.updateRule(entityId, groupId, attrId, ruleId, patch)
|
||||
}
|
||||
|
||||
function deleteRule(ruleId: string) {
|
||||
editor.deleteRule(entityId, groupId, attrId, ruleId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
{#if rules.length === 0}
|
||||
<p class="text-xs text-gray-700 italic px-1">Sin reglas</p>
|
||||
{:else}
|
||||
{#each rules as rule (rule.id)}
|
||||
<RuleEditor
|
||||
{rule}
|
||||
onchange={(patch) => updateRule(rule.id!, patch)}
|
||||
ondelete={() => deleteRule(rule.id!)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={addRule}
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-md border border-dashed
|
||||
border-gray-700 text-xs text-gray-600 hover:text-indigo-400
|
||||
hover:border-indigo-700 transition-colors"
|
||||
>
|
||||
+ Añadir regla
|
||||
</button>
|
||||
|
||||
</div>
|
||||
@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore, EditorPanel } from '../stores/editor.svelte.ts'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
const nav: Array<{ panel: EditorPanel; label: string; icon: string }> = [
|
||||
{ panel: 'catalog', label: 'Catálogo', icon: '⬡' },
|
||||
{ panel: 'objects', label: 'Objetos', icon: '▣' },
|
||||
{ panel: 'options', label: 'Opciones', icon: '◈' },
|
||||
{ panel: 'rules', label: 'Reglas', icon: '⚖' },
|
||||
{ panel: 'preview', label: 'Preview', icon: '◉' },
|
||||
{ panel: 'export', label: 'Exportar', icon: '⬡' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<nav class="w-48 shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col py-4 gap-1">
|
||||
{#each nav as item}
|
||||
<button
|
||||
onclick={() => editor.setPanel(item.panel)}
|
||||
class="flex items-center gap-3 px-4 py-2.5 text-sm text-left transition-colors
|
||||
{editor.activePanel === item.panel
|
||||
? 'bg-indigo-600/20 text-indigo-300 border-r-2 border-indigo-500'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'}"
|
||||
>
|
||||
<span class="text-base">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
|
||||
<!-- Badge errores -->
|
||||
{#if editor.validation.errors.some(e => e.path.startsWith(item.panel))}
|
||||
<span class="ml-auto w-1.5 h-1.5 rounded-full bg-red-400"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
@ -1,74 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { ling } from '@/ling/lib'
|
||||
import type { EditorStore } from '../stores/editor.svelte.ts'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
|
||||
console.log(editor.validation)
|
||||
console.log('isDirty ',editor.isDirty);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<header class="flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-800 shrink-0">
|
||||
|
||||
<!-- Logo + nombre colección -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-bold tracking-widest text-indigo-400 uppercase">VICE</span>
|
||||
<span class="text-gray-600">|</span>
|
||||
<span class="text-sm text-gray-300">
|
||||
{ling.ts(editor.collection.name) || 'Nueva colección'}
|
||||
</span>
|
||||
{#if editor.isDirty}
|
||||
<span class="text-xs text-amber-400">● Sin guardar</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Acciones -->
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<!-- Validación -->
|
||||
{#if !editor.validation.valid}
|
||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-400 inline-block"></span>
|
||||
{editor.validation.errors.length} error{editor.validation.errors.length !== 1 ? 'es' : ''}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="flex items-center gap-1 text-xs text-emerald-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 inline-block"></span>
|
||||
Válido
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Preview -->
|
||||
<button
|
||||
onclick={() => editor.setPanel('preview')}
|
||||
disabled={!editor.canPreview}
|
||||
class="px-3 py-1.5 text-xs rounded-md border border-gray-700 text-gray-300
|
||||
hover:bg-gray-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Vista previa
|
||||
</button>
|
||||
|
||||
<!-- Export -->
|
||||
<button
|
||||
onclick={() => editor.setPanel('export')}
|
||||
class="px-3 py-1.5 text-xs rounded-md bg-indigo-600 hover:bg-indigo-500
|
||||
text-white font-medium transition-colors"
|
||||
>
|
||||
Exportar JSON
|
||||
</button>
|
||||
|
||||
<!-- Reset -->
|
||||
<button
|
||||
onclick={() => { if (confirm('¿Resetear el editor? Se perderán todos los cambios.')) editor.reset(); }}
|
||||
class="px-3 py-1.5 text-xs rounded-md border border-gray-700 text-gray-500
|
||||
hover:text-red-400 hover:border-red-800 transition-colors"
|
||||
>
|
||||
Nuevo
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
@ -1,191 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { setContext } from 'svelte'
|
||||
import { createEditorStore } from '../stores/editor.svelte.ts'
|
||||
import type { EditorStore } from '../stores/editor.svelte.ts'
|
||||
import type { Collection } from '@/vice/libs/types'
|
||||
import EditorLayout from '../layouts/EditorLayout.svelte'
|
||||
|
||||
let { initial }: { initial?: Collection } = $props()
|
||||
|
||||
const editor = createEditorStore(initial)
|
||||
setContext<EditorStore>('editor', editor)
|
||||
</script>
|
||||
|
||||
<EditorLayout />
|
||||
|
||||
<style>
|
||||
/* ── Utilidades globales del editor ─────────────────────────────────── */
|
||||
|
||||
:global(.field-input) {
|
||||
width: 100%;
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #e2e8f0;
|
||||
outline: none;
|
||||
transition: border-color 120ms;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
:global(.field-input:focus) {
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
:global(.field-input.border-red-800) {
|
||||
border-color: #991b1b;
|
||||
}
|
||||
|
||||
:global(select.field-input) {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
padding-right: 1.75rem;
|
||||
}
|
||||
|
||||
:global(textarea.field-input) {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
:global(.section-title) {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #6b7280;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
}
|
||||
|
||||
:global(.section-title-inline) {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* ── Editor root ─────────────────────────────────────────────────────── */
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #0a0f1a;
|
||||
color: #e2e8f0;
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
.editor-nav {
|
||||
width: 11rem;
|
||||
shrink: 0;
|
||||
border-right: 1px solid #1e293b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #080d17;
|
||||
}
|
||||
|
||||
.editor-nav-header {
|
||||
padding: 1.25rem 1rem 1rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.editor-logo {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.editor-logo-sub {
|
||||
font-size: 0.6875rem;
|
||||
color: #334155;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.editor-tabs {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.editor-tab {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #475569;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: color 120ms, background 120ms;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.editor-tab:hover {
|
||||
color: #94a3b8;
|
||||
background: rgb(255 255 255 / 0.02);
|
||||
}
|
||||
|
||||
.editor-tab-active {
|
||||
color: #a5b4fc;
|
||||
border-left-color: #6366f1;
|
||||
background: rgb(99 102 241 / 0.08);
|
||||
}
|
||||
|
||||
.editor-tab-error {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(239 68 68 / 0.2);
|
||||
color: #fca5a5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.editor-nav-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid #1e293b;
|
||||
}
|
||||
|
||||
.editor-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.editor-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
shrink: 0;
|
||||
}
|
||||
|
||||
.editor-status-error { color: #f87171; }
|
||||
.editor-status-error .editor-status-dot { background: #ef4444; }
|
||||
|
||||
.editor-status-warn { color: #fbbf24; }
|
||||
.editor-status-warn .editor-status-dot { background: #f59e0b; }
|
||||
|
||||
.editor-status-ok { color: #4ade80; }
|
||||
.editor-status-ok .editor-status-dot { background: #22c55e; }
|
||||
|
||||
/* Main */
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@ -1,44 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore } from '../stores/editor.svelte.ts'
|
||||
import TopBar from '../comps/TopBar.svelte'
|
||||
import Sidebar from '../comps/Sidebar.svelte'
|
||||
import CollectionPanel from "@/vice/editor/comps/CollectionPanel.svelte";
|
||||
import OptionsPanel from "@/vice/editor/comps/OptionsPanel.svelte";
|
||||
import EntitiesPanel from "@/vice/editor/comps/EntitiesPanel.svelte";
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-screen bg-gray-950 text-gray-100 overflow-hidden">
|
||||
|
||||
<TopBar />
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
|
||||
<Sidebar />
|
||||
|
||||
<main class="flex-1 overflow-auto">
|
||||
{#if editor.activePanel === 'catalog'}
|
||||
<CollectionPanel />
|
||||
<div class="p-8 text-gray-600 text-sm">CatalogPanel — en construcción</div>
|
||||
{:else if editor.activePanel === 'objects'}
|
||||
<EntitiesPanel />
|
||||
<div class="p-8 text-gray-600 text-sm">ObjectsPanel — en construcción</div>
|
||||
{:else if editor.activePanel === 'options'}
|
||||
<OptionsPanel />
|
||||
<div class="p-8 text-gray-600 text-sm">OptionsPanel — en construcción</div>
|
||||
{:else if editor.activePanel === 'rules'}
|
||||
<!-- <RulesPanel /> -->
|
||||
<div class="p-8 text-gray-600 text-sm">RulesPanel — en construcción</div>
|
||||
{:else if editor.activePanel === 'preview'}
|
||||
<!-- <PreviewPanel /> -->
|
||||
<div class="p-8 text-gray-600 text-sm">PreviewPanel — en construcción</div>
|
||||
{:else if editor.activePanel === 'export'}
|
||||
<!-- <ExportPanel /> -->
|
||||
<div class="p-8 text-gray-600 text-sm">ExportPanel — en construcción</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@ -1,606 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · Editor Store
|
||||
//
|
||||
// Store principal del editor de catálogos.
|
||||
// Gestiona el estado de edición de una Collection completa.
|
||||
// Usa runas de Svelte 5 ($state, $derived).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type {
|
||||
Attribute,
|
||||
AttributeOption,
|
||||
AttrID,
|
||||
Collection,
|
||||
CollectionID,
|
||||
Entity,
|
||||
EntityID,
|
||||
FixedGroup,
|
||||
Group,
|
||||
GroupID,
|
||||
OptionalGroup,
|
||||
OptionDefinition,
|
||||
OptionID,
|
||||
RequiredAttribute,
|
||||
} from '@/vice/libs/types'
|
||||
import type {LingString} from "@/ling";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HELPERS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function uid(prefix: string): string {
|
||||
return `${prefix}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function emptyLing(es = '', en = ''): LingString {
|
||||
return { es, en }
|
||||
}
|
||||
|
||||
function emptyCollection(): Collection {
|
||||
return {
|
||||
id: uid('col') as CollectionID,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
attributes: [],
|
||||
options: {} as Record<OptionID, OptionDefinition>,
|
||||
entities: {} as Record<EntityID, Entity>,
|
||||
}
|
||||
}
|
||||
|
||||
function emptyEntity(id: EntityID): Entity {
|
||||
return {
|
||||
id,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
attributes: [],
|
||||
groups: {} as Record<GroupID, Group>,
|
||||
rules: [],
|
||||
}
|
||||
}
|
||||
|
||||
function emptyGroup(id: GroupID, kind: 'fixed' | 'optional' = 'fixed'): Group {
|
||||
const base = {
|
||||
id,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
attributes: [],
|
||||
}
|
||||
if (kind === 'optional') {
|
||||
return { ...base, kind: 'optional', rules: [] } satisfies OptionalGroup
|
||||
}
|
||||
return { ...base, kind: 'fixed' } satisfies FixedGroup
|
||||
}
|
||||
|
||||
function emptyAttribute(id: AttrID): RequiredAttribute {
|
||||
return {
|
||||
id,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
type: 'required',
|
||||
default: '' as OptionID,
|
||||
options: [],
|
||||
rules: [],
|
||||
display: {},
|
||||
}
|
||||
}
|
||||
|
||||
function emptyOption(id: OptionID): OptionDefinition {
|
||||
return {
|
||||
id,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
tags: [],
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VALIDATION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EditorIssue {
|
||||
severity: 'error' | 'warning'
|
||||
message: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface EditorValidation {
|
||||
valid: boolean
|
||||
errors: EditorIssue[]
|
||||
warnings: EditorIssue[]
|
||||
}
|
||||
|
||||
function validateCollection(collection: Collection): EditorValidation {
|
||||
const errors: EditorIssue[] = []
|
||||
const warnings: EditorIssue[] = []
|
||||
|
||||
// Catálogo
|
||||
if (!collection.code) errors.push({ severity: 'error', message: 'El catálogo necesita un código', path: 'collection.code' })
|
||||
if (!getLing(collection.name)) errors.push({ severity: 'error', message: 'El catálogo necesita un nombre', path: 'collection.name' })
|
||||
|
||||
// Opciones globales
|
||||
for (const opt of Object.values(collection.options)) {
|
||||
if (!opt.code) errors.push({ severity: 'error', message: `Opción "${opt.id}" sin código`, path: `options.${opt.id}.code` })
|
||||
if (!getLing(opt.name)) errors.push({ severity: 'error', message: `Opción "${opt.id}" sin nombre`, path: `options.${opt.id}.name` })
|
||||
}
|
||||
|
||||
// Entidades
|
||||
for (const entity of Object.values(collection.entities)) {
|
||||
if (!entity.code) errors.push({ severity: 'error', message: `Entidad "${entity.id}" sin código`, path: `entities.${entity.id}.code` })
|
||||
if (!getLing(entity.name)) errors.push({ severity: 'error', message: `Entidad "${entity.id}" sin nombre`, path: `entities.${entity.id}.name` })
|
||||
|
||||
// Grupos
|
||||
for (const [groupId, group] of Object.entries(entity.groups)) {
|
||||
if (!group.code) errors.push({ severity: 'error', message: `Grupo "${groupId}" sin código`, path: `entities.${entity.id}.groups.${groupId}.code` })
|
||||
if (!getLing(group.name)) errors.push({ severity: 'error', message: `Grupo "${groupId}" sin nombre`, path: `entities.${entity.id}.groups.${groupId}.name` })
|
||||
|
||||
// Atributos del grupo
|
||||
for (const attr of group.attributes) {
|
||||
validateAttribute(attr, `entities.${entity.id}.groups.${groupId}`, collection, errors, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// Atributos globales de la entidad
|
||||
for (const attr of entity.attributes) {
|
||||
validateAttribute(attr, `entities.${entity.id}`, collection, errors, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// Atributos globales del catálogo
|
||||
for (const attr of collection.attributes) {
|
||||
validateAttribute(attr, 'collection', collection, errors, warnings)
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings }
|
||||
}
|
||||
|
||||
function validateAttribute(
|
||||
attr: Attribute,
|
||||
scope: string,
|
||||
collection: Collection,
|
||||
errors: EditorIssue[],
|
||||
warnings: EditorIssue[],
|
||||
): void {
|
||||
const path = `${scope}.attributes.${attr.id}`
|
||||
if (!attr.code) errors.push({ severity: 'error', message: `Atributo "${attr.id}" sin código`, path: `${path}.code` })
|
||||
if (!getLing(attr.name)) errors.push({ severity: 'error', message: `Atributo "${attr.id}" sin nombre`, path: `${path}.name` })
|
||||
|
||||
if (attr.type !== 'fixed') {
|
||||
if (attr.options.length === 0) {
|
||||
warnings.push({ severity: 'warning', message: `Atributo "${attr.id}" sin opciones`, path: `${path}.options` })
|
||||
} else {
|
||||
// Verificar que el default existe en las opciones
|
||||
const hasDefault = attr.options.some(o => o.optionId === attr.default)
|
||||
if (!hasDefault) {
|
||||
errors.push({ severity: 'error', message: `Atributo "${attr.id}": el valor por defecto no está en las opciones`, path: `${path}.default` })
|
||||
}
|
||||
// Verificar que todas las opciones existen en la librería
|
||||
for (const attrOpt of attr.options) {
|
||||
if (!collection.options[attrOpt.optionId]) {
|
||||
errors.push({ severity: 'error', message: `Atributo "${attr.id}": opción "${attrOpt.optionId}" no existe en la librería`, path: `${path}.options.${attrOpt.optionId}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLing(l: LingString): string {
|
||||
return typeof l === 'string' ? l : (l as any)?.es ?? ''
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// EDITOR STORE
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createEditorStore(initial?: Collection) {
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let collection = $state<Collection>(initial ?? emptyCollection())
|
||||
let activeEntityId = $state<EntityID | null>(null)
|
||||
let activeGroupId = $state<GroupID | null>(null)
|
||||
let activeAttrId = $state<AttrID | null>(null)
|
||||
let activeOptionId = $state<OptionID | null>(null)
|
||||
let activeRuleIdx = $state<number | null>(null)
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
const validation = $derived(validateCollection(collection))
|
||||
const activeEntity = $derived(activeEntityId ? collection.entities[activeEntityId] ?? null : null)
|
||||
const activeGroup = $derived(
|
||||
activeEntity && activeGroupId ? (activeEntity.groups[activeGroupId] ?? null) : null
|
||||
)
|
||||
const activeAttribute = $derived(() => {
|
||||
if (!activeAttrId) return null
|
||||
if (activeGroup) return activeGroup.attributes.find(a => a.id === activeAttrId) ?? null
|
||||
if (activeEntity) return activeEntity.attributes.find(a => a.id === activeAttrId) ?? null
|
||||
return collection.attributes.find(a => a.id === activeAttrId) ?? null
|
||||
})
|
||||
const activeOption = $derived(activeOptionId ? collection.options[activeOptionId] ?? null : null)
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
|
||||
function setActiveEntity(id: EntityID | null) {
|
||||
activeEntityId = id
|
||||
activeGroupId = null
|
||||
activeAttrId = null
|
||||
}
|
||||
|
||||
function setActiveGroup(id: GroupID | null) {
|
||||
activeGroupId = id
|
||||
activeAttrId = null
|
||||
}
|
||||
|
||||
function setActiveAttribute(id: AttrID | null) {
|
||||
activeAttrId = id
|
||||
}
|
||||
|
||||
function setActiveOption(id: OptionID | null) {
|
||||
activeOptionId = id
|
||||
}
|
||||
|
||||
// ── Collection meta ────────────────────────────────────────────────────────
|
||||
|
||||
function updateCollectionMeta(patch: Partial<Pick<Collection, 'code' | 'name' | 'description' | 'basePath'>>) {
|
||||
collection = { ...collection, ...patch }
|
||||
}
|
||||
|
||||
// ── Options library ────────────────────────────────────────────────────────
|
||||
|
||||
function addOption(): OptionID {
|
||||
const id = uid('op') as OptionID
|
||||
collection = {
|
||||
...collection,
|
||||
options: { ...collection.options, [id]: emptyOption(id) },
|
||||
}
|
||||
activeOptionId = id
|
||||
return id
|
||||
}
|
||||
|
||||
function updateOption(id: OptionID, patch: Partial<OptionDefinition>) {
|
||||
const opt = collection.options[id]
|
||||
if (!opt) return
|
||||
collection = {
|
||||
...collection,
|
||||
options: { ...collection.options, [id]: { ...opt, ...patch } },
|
||||
}
|
||||
}
|
||||
|
||||
function deleteOption(id: OptionID) {
|
||||
const { [id]: _, ...rest } = collection.options
|
||||
collection = { ...collection, options: rest as Record<OptionID, OptionDefinition> }
|
||||
if (activeOptionId === id) activeOptionId = null
|
||||
}
|
||||
|
||||
function duplicateOption(id: OptionID): OptionID {
|
||||
const opt = collection.options[id]
|
||||
if (!opt) return id
|
||||
const newId = uid('op') as OptionID
|
||||
const name = typeof opt.name === 'string'
|
||||
? opt.name + ' (copia)'
|
||||
: { ...(opt.name as any), es: ((opt.name as any).es ?? '') + ' (copia)' }
|
||||
collection = {
|
||||
...collection,
|
||||
options: { ...collection.options, [newId]: { ...opt, id: newId, code: opt.code + '_copy', name } },
|
||||
}
|
||||
activeOptionId = newId
|
||||
return newId
|
||||
}
|
||||
|
||||
// ── Entities ───────────────────────────────────────────────────────────────
|
||||
|
||||
function addEntity(): EntityID {
|
||||
const id = uid('en') as EntityID
|
||||
collection = {
|
||||
...collection,
|
||||
entities: { ...collection.entities, [id]: emptyEntity(id) },
|
||||
}
|
||||
activeEntityId = id
|
||||
activeGroupId = null
|
||||
activeAttrId = null
|
||||
return id
|
||||
}
|
||||
|
||||
function updateEntity(id: EntityID, patch: Record<string, unknown>) {
|
||||
const entity = collection.entities[id]
|
||||
if (!entity) return
|
||||
collection = {
|
||||
...collection,
|
||||
entities: { ...collection.entities, [id]: { ...entity, ...patch } as Entity },
|
||||
}
|
||||
}
|
||||
|
||||
function deleteEntity(id: EntityID) {
|
||||
const { [id]: _, ...rest } = collection.entities
|
||||
collection = { ...collection, entities: rest as Record<EntityID, Entity> }
|
||||
if (activeEntityId === id) setActiveEntity(null)
|
||||
}
|
||||
|
||||
// ── Groups ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function addGroup(entityId: EntityID, kind: 'fixed' | 'optional' = 'fixed'): GroupID {
|
||||
const entity = collection.entities[entityId]
|
||||
if (!entity) return '' as GroupID
|
||||
const id = uid('gr') as GroupID
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
groups: { ...entity.groups, [id]: emptyGroup(id, kind) },
|
||||
},
|
||||
},
|
||||
}
|
||||
activeGroupId = id
|
||||
activeAttrId = null
|
||||
return id
|
||||
}
|
||||
|
||||
function updateGroup(entityId: EntityID, groupId: GroupID, patch: Record<string, unknown>) {
|
||||
const entity = collection.entities[entityId]
|
||||
const group = entity?.groups[groupId]
|
||||
if (!entity || !group) return
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
groups: { ...entity.groups, [groupId]: { ...group, ...patch } as Group },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function deleteGroup(entityId: EntityID, groupId: GroupID) {
|
||||
const entity = collection.entities[entityId]
|
||||
if (!entity) return
|
||||
const { [groupId]: _, ...rest } = entity.groups
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: { ...entity, groups: rest as Record<GroupID, Group> },
|
||||
},
|
||||
}
|
||||
if (activeGroupId === groupId) { activeGroupId = null; activeAttrId = null }
|
||||
}
|
||||
|
||||
// ── Attributes ─────────────────────────────────────────────────────────────
|
||||
|
||||
function addAttribute(entityId: EntityID, groupId?: GroupID): AttrID {
|
||||
const id = uid('at') as AttrID
|
||||
const attr = emptyAttribute(id)
|
||||
|
||||
if (groupId) {
|
||||
collection = produceAttr(collection, entityId, groupId, id, () => attr)
|
||||
// produceAttr filtra por attrId que no existe aún — necesitamos append
|
||||
const entity = collection.entities[entityId]
|
||||
const group = entity?.groups[groupId]
|
||||
if (entity && group) {
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
groups: {
|
||||
...entity.groups,
|
||||
[groupId]: { ...group, attributes: [...group.attributes, attr] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const entity = collection.entities[entityId]
|
||||
if (entity) {
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: { ...entity, attributes: [...entity.attributes, attr] },
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activeAttrId = id
|
||||
return id
|
||||
}
|
||||
|
||||
function updateAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID, patch: Record<string, unknown>) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, attr => ({ ...attr, ...patch }) as Attribute)
|
||||
}
|
||||
|
||||
function deleteAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, null)
|
||||
if (activeAttrId === attrId) activeAttrId = null
|
||||
}
|
||||
|
||||
// ── Attribute options ──────────────────────────────────────────────────────
|
||||
|
||||
function addOptionToAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID, optionId: OptionID) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, attr => {
|
||||
if (attr.type === 'fixed') return attr
|
||||
if (attr.options.some(o => o.optionId === optionId)) return attr
|
||||
const newOpt: AttributeOption = { optionId }
|
||||
const options = [...attr.options, newOpt]
|
||||
// Si es la primera opción, ponerla como default
|
||||
const updated = { ...attr, options }
|
||||
if (!attr.default && options.length === 1) (updated as any).default = optionId
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
function removeOptionFromAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID, optionId: OptionID) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, attr => {
|
||||
if (attr.type === 'fixed') return attr
|
||||
const options = attr.options.filter(o => o.optionId !== optionId)
|
||||
const updated = { ...attr, options }
|
||||
if ((attr as any).default === optionId) (updated as any).default = options[0]?.optionId ?? ''
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
// ── Catalog attributes ─────────────────────────────────────────────────────
|
||||
|
||||
function addCatalogAttribute(): AttrID {
|
||||
const id = uid('at') as AttrID
|
||||
const attr = emptyAttribute(id)
|
||||
collection = { ...collection, attributes: [...collection.attributes, attr] }
|
||||
activeAttrId = id
|
||||
activeEntityId = null
|
||||
activeGroupId = null
|
||||
return id
|
||||
}
|
||||
|
||||
function updateCatalogAttribute(attrId: AttrID, patch: Record<string, unknown>) {
|
||||
collection = {
|
||||
...collection,
|
||||
attributes: collection.attributes.map(a =>
|
||||
a.id === attrId ? { ...a, ...patch } as Attribute : a
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCatalogAttribute(attrId: AttrID) {
|
||||
collection = { ...collection, attributes: collection.attributes.filter(a => a.id !== attrId) }
|
||||
if (activeAttrId === attrId) activeAttrId = null
|
||||
}
|
||||
|
||||
// ── Import / Export ────────────────────────────────────────────────────────
|
||||
|
||||
function exportToJson(): string {
|
||||
return JSON.stringify(collection, null, 2)
|
||||
}
|
||||
|
||||
function importFromJson(json: string) {
|
||||
collection = JSON.parse(json)
|
||||
activeEntityId = null
|
||||
activeGroupId = null
|
||||
activeAttrId = null
|
||||
activeOptionId = null
|
||||
}
|
||||
|
||||
function reset() {
|
||||
collection = emptyCollection()
|
||||
activeEntityId = null
|
||||
activeGroupId = null
|
||||
activeAttrId = null
|
||||
activeOptionId = null
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
// State (getters)
|
||||
get collection() { return collection },
|
||||
get validation() { return validation },
|
||||
get activeEntityId() { return activeEntityId },
|
||||
get activeGroupId() { return activeGroupId },
|
||||
get activeAttrId() { return activeAttrId },
|
||||
get activeOptionId() { return activeOptionId },
|
||||
get activeEntity() { return activeEntity },
|
||||
get activeGroup() { return activeGroup },
|
||||
get activeAttribute() { return activeAttribute() },
|
||||
get activeOption() { return activeOption },
|
||||
|
||||
// Navigation
|
||||
setActiveEntity,
|
||||
setActiveGroup,
|
||||
setActiveAttribute,
|
||||
setActiveOption,
|
||||
|
||||
// Collection
|
||||
updateCollectionMeta,
|
||||
|
||||
// Options
|
||||
addOption,
|
||||
updateOption,
|
||||
deleteOption,
|
||||
duplicateOption,
|
||||
|
||||
// Entities
|
||||
addEntity,
|
||||
updateEntity,
|
||||
deleteEntity,
|
||||
|
||||
// Groups
|
||||
addGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
|
||||
// Attributes
|
||||
addAttribute,
|
||||
updateAttribute,
|
||||
deleteAttribute,
|
||||
addCatalogAttribute,
|
||||
updateCatalogAttribute,
|
||||
deleteCatalogAttribute,
|
||||
|
||||
// Attribute options
|
||||
addOptionToAttribute,
|
||||
removeOptionFromAttribute,
|
||||
|
||||
// Import/Export
|
||||
exportToJson,
|
||||
importFromJson,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export type EditorStore = ReturnType<typeof createEditorStore>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HELPERS INTERNOS — produce inmutable
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
function produceAttr(
|
||||
collection: Collection,
|
||||
entityId: EntityID,
|
||||
groupId: GroupID | undefined,
|
||||
attrId: AttrID,
|
||||
fn: ((attr: Attribute) => Attribute) | null,
|
||||
): Collection {
|
||||
const entity = collection.entities[entityId]
|
||||
if (!entity) return collection
|
||||
|
||||
function mapAttrs(attrs: Attribute[]): Attribute[] {
|
||||
if (fn === null) return attrs.filter(a => a.id !== attrId)
|
||||
return attrs.map(a => a.id === attrId ? fn(a) as Attribute : a)
|
||||
}
|
||||
|
||||
if (groupId) {
|
||||
const group = entity.groups[groupId]
|
||||
if (!group) return collection
|
||||
return {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
groups: {
|
||||
...entity.groups,
|
||||
[groupId]: { ...group, attributes: mapAttrs(group.attributes) },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: { ...entity, attributes: mapAttrs(entity.attributes) },
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,812 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · Editor Store
|
||||
//
|
||||
// Store principal del editor de catálogos.
|
||||
// Gestiona el estado de edición de una Collection completa.
|
||||
// Usa runas de Svelte 5 ($state, $derived).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type {
|
||||
Collection, CollectionID,
|
||||
Entity, EntityID,
|
||||
Group, GroupID,
|
||||
Attribute, AttrID,
|
||||
AttributeOption,
|
||||
OptionDefinition, OptionID,
|
||||
Rule,
|
||||
EntityPath,
|
||||
EntityPathKey,
|
||||
LiteralValue,
|
||||
} from '@/vice/libs/types'
|
||||
import type {FixedGroup, OptionalGroup} from "@/vice/libs";
|
||||
import type {LingString} from "@/ling/lib";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HELPERS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function uid(prefix: string): string {
|
||||
return `${prefix}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function emptyLing(es = '', en = ''): LingString {
|
||||
return { es, en }
|
||||
}
|
||||
|
||||
function emptyCollection(): Collection {
|
||||
return {
|
||||
id: uid('col') as CollectionID,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
langs: ['es'],
|
||||
attributes: [],
|
||||
options: {} as Record<OptionID, OptionDefinition>,
|
||||
entities: {} as Record<EntityID, Entity>,
|
||||
}
|
||||
}
|
||||
|
||||
function emptyEntity(id: EntityID): Entity {
|
||||
return {
|
||||
id,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
attributes: [],
|
||||
groups: {} as Record<GroupID, Group>,
|
||||
rules: [],
|
||||
}
|
||||
}
|
||||
|
||||
function emptyGroup(id: GroupID, kind: 'fixed' | 'optional' = 'fixed'): Group {
|
||||
const base = {
|
||||
id,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
attributes: [],
|
||||
}
|
||||
if (kind === 'optional') {
|
||||
return { ...base, kind: 'optional', rules: [] } satisfies OptionalGroup
|
||||
}
|
||||
return { ...base, kind: 'fixed' } satisfies FixedGroup
|
||||
}
|
||||
|
||||
function emptyAttribute(id: AttrID): import('@/vice/libs/types.ts').RequiredAttribute {
|
||||
return {
|
||||
id,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
type: 'required',
|
||||
domain: { kind: 'options', options: [], default: '' as OptionID },
|
||||
rules: [],
|
||||
display: {},
|
||||
}
|
||||
}
|
||||
|
||||
function emptyFixedAttribute(id: AttrID): import('@/vice/libs/types.ts').FixedAttribute {
|
||||
return {
|
||||
id,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
type: 'fixed',
|
||||
domain: { kind: 'fixed', value: { kind: 'string', value: '' } },
|
||||
display: {},
|
||||
}
|
||||
}
|
||||
|
||||
function emptyOption(id: OptionID): OptionDefinition {
|
||||
return {
|
||||
id,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
tags: [],
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VALIDATION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type EditorPanel = 'catalog' | 'objects' | 'options' | 'rules' | 'preview' | 'export'
|
||||
|
||||
export interface EditorIssue {
|
||||
severity: 'error' | 'warning'
|
||||
message: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface EditorValidation {
|
||||
valid: boolean
|
||||
errors: EditorIssue[]
|
||||
warnings: EditorIssue[]
|
||||
}
|
||||
|
||||
function validateCollection(collection: Collection): EditorValidation {
|
||||
const errors: EditorIssue[] = []
|
||||
const warnings: EditorIssue[] = []
|
||||
|
||||
// Catálogo
|
||||
if (!collection.code) errors.push({ severity: 'error', message: 'El catálogo necesita un código', path: 'collection.code' })
|
||||
|
||||
// Validar nombre en todos los langs declarados
|
||||
const name = collection.name as any
|
||||
for (const lang of collection.langs) {
|
||||
if (!name?.[lang]) {
|
||||
errors.push({ severity: 'error', message: `El catálogo necesita nombre en "${lang}"`, path: `collection.name.${lang}` })
|
||||
}
|
||||
}
|
||||
|
||||
// Opciones globales
|
||||
for (const opt of Object.values(collection.options)) {
|
||||
if (!opt.code) errors.push({ severity: 'error', message: `Opción "${opt.id}" sin código`, path: `options.${opt.id}.code` })
|
||||
|
||||
const optName = opt.name as any
|
||||
for (const lang of collection.langs) {
|
||||
if (!optName?.[lang]) {
|
||||
warnings.push({ severity: 'warning', message: `Opción "${opt.id}" sin nombre en "${lang}"`, path: `options.${opt.id}.name.${lang}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entidades
|
||||
for (const entity of Object.values(collection.entities)) {
|
||||
if (!entity.code) errors.push({ severity: 'error', message: `Entidad "${entity.id}" sin código`, path: `entities.${entity.id}.code` })
|
||||
|
||||
const entityName = entity.name as any
|
||||
for (const lang of collection.langs) {
|
||||
if (!entityName?.[lang]) {
|
||||
warnings.push({ severity: 'warning', message: `Entidad "${entity.id}" sin nombre en "${lang}"`, path: `entities.${entity.id}.name.${lang}` })
|
||||
}
|
||||
}
|
||||
|
||||
// Grupos
|
||||
for (const [groupId, group] of Object.entries(entity.groups)) {
|
||||
if (!group.code) errors.push({ severity: 'error', message: `Grupo "${groupId}" sin código`, path: `entities.${entity.id}.groups.${groupId}.code` })
|
||||
|
||||
const groupName = group.name as any
|
||||
for (const lang of collection.langs) {
|
||||
if (!groupName?.[lang]) {
|
||||
warnings.push({ severity: 'warning', message: `Grupo "${groupId}" sin nombre en "${lang}"`, path: `entities.${entity.id}.groups.${groupId}.name.${lang}` })
|
||||
}
|
||||
}
|
||||
|
||||
// Atributos del grupo
|
||||
for (const attr of group.attributes) {
|
||||
validateAttribute(attr, `entities.${entity.id}.groups.${groupId}`, collection, errors, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// Atributos globales de la entidad
|
||||
for (const attr of entity.attributes) {
|
||||
validateAttribute(attr, `entities.${entity.id}`, collection, errors, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// Atributos globales del catálogo
|
||||
for (const attr of collection.attributes) {
|
||||
validateAttribute(attr, 'collection', collection, errors, warnings)
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings }
|
||||
}
|
||||
|
||||
function validateAttribute(
|
||||
attr: Attribute,
|
||||
scope: string,
|
||||
collection: Collection,
|
||||
errors: EditorIssue[],
|
||||
warnings: EditorIssue[],
|
||||
): void {
|
||||
const path = `${scope}.attributes.${attr.id}`
|
||||
if (!attr.code) errors.push({ severity: 'error', message: `Atributo "${attr.id}" sin código`, path: `${path}.code` })
|
||||
|
||||
const attrName = attr.name as any
|
||||
for (const lang of collection.langs) {
|
||||
if (!attrName?.[lang]) {
|
||||
warnings.push({ severity: 'warning', message: `Atributo "${attr.id}" sin nombre en "${lang}"`, path: `${path}.name.${lang}` })
|
||||
}
|
||||
}
|
||||
|
||||
if (attr.type !== 'fixed') {
|
||||
const domain = attr.domain
|
||||
if (domain.kind === 'options') {
|
||||
if (domain.options.length === 0) {
|
||||
warnings.push({ severity: 'warning', message: `Atributo "${attr.id}" sin opciones`, path: `${path}.domain.options` })
|
||||
} else {
|
||||
const hasDefault = domain.options.some(o => o.optionId === domain.default)
|
||||
if (!hasDefault) {
|
||||
errors.push({ severity: 'error', message: `Atributo "${attr.id}": el valor por defecto no está en las opciones`, path: `${path}.domain.default` })
|
||||
}
|
||||
for (const attrOpt of domain.options) {
|
||||
if (!collection.options[attrOpt.optionId]) {
|
||||
errors.push({ severity: 'error', message: `Atributo "${attr.id}": opción "${attrOpt.optionId}" no existe en la librería`, path: `${path}.domain.options.${attrOpt.optionId}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (domain.kind === 'literals') {
|
||||
if (domain.literals.length === 0) {
|
||||
warnings.push({ severity: 'warning', message: `Atributo "${attr.id}" sin literales en el dominio`, path: `${path}.domain.literals` })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLing(l: LingString): string {
|
||||
return typeof l === 'string' ? l : (l as any)?.es ?? ''
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// EDITOR STORE
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createEditorStore(initial?: Collection) {
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let collection = $state<Collection>(initial ?? emptyCollection())
|
||||
let activeEntityId = $state<EntityID | null>(null)
|
||||
let activeGroupId = $state<GroupID | null>(null)
|
||||
let activeAttrId = $state<AttrID | null>(null)
|
||||
let activeOptionId = $state<OptionID | null>(null)
|
||||
let activeRuleIdx = $state<number | null>(null)
|
||||
let activePanel = $state<EditorPanel>('objects')
|
||||
let isDirty = $state(false)
|
||||
|
||||
function markDirty() { isDirty = true }
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
const validation = $derived(validateCollection(collection))
|
||||
const canPreview = $derived(
|
||||
validation.valid && Object.keys(collection.entities).length > 0
|
||||
)
|
||||
const activeEntity = $derived(activeEntityId ? collection.entities[activeEntityId] ?? null : null)
|
||||
const activeGroup = $derived(
|
||||
activeEntity && activeGroupId ? (activeEntity.groups[activeGroupId] ?? null) : null
|
||||
)
|
||||
const activeAttribute = $derived((() => {
|
||||
if (!activeAttrId) return null
|
||||
if (activeGroup) return activeGroup.attributes.find(a => a.id === activeAttrId) ?? null
|
||||
if (activeEntity) return activeEntity.attributes.find(a => a.id === activeAttrId) ?? null
|
||||
return collection.attributes.find(a => a.id === activeAttrId) ?? null
|
||||
})())
|
||||
const activeOption = $derived(activeOptionId ? collection.options[activeOptionId] ?? null : null)
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
|
||||
function setPanel(panel: EditorPanel) {
|
||||
activePanel = panel
|
||||
}
|
||||
|
||||
function setActiveEntity(id: EntityID | null) {
|
||||
activeEntityId = id
|
||||
activeGroupId = null
|
||||
activeAttrId = null
|
||||
}
|
||||
|
||||
function setActiveGroup(id: GroupID | null) {
|
||||
activeGroupId = id
|
||||
activeAttrId = null
|
||||
}
|
||||
|
||||
function setActiveAttribute(id: AttrID | null) {
|
||||
activeAttrId = id
|
||||
}
|
||||
|
||||
function setActiveEntityAttribute(id: AttrID | null) {
|
||||
activeGroupId = null
|
||||
activeAttrId = id
|
||||
}
|
||||
|
||||
function setActiveOption(id: OptionID | null) {
|
||||
activeOptionId = id
|
||||
}
|
||||
|
||||
// ── Collection meta ────────────────────────────────────────────────────────
|
||||
|
||||
function updateCollectionMeta(patch: Partial<Pick<Collection, 'code' | 'name' | 'description' | 'basePath' | 'langs'>>) {
|
||||
collection = { ...collection, ...patch }
|
||||
isDirty = true
|
||||
}
|
||||
|
||||
// ── Options library ────────────────────────────────────────────────────────
|
||||
|
||||
function addOption(): OptionID {
|
||||
const id = uid('op') as OptionID
|
||||
collection = {
|
||||
...collection,
|
||||
options: { ...collection.options, [id]: emptyOption(id) },
|
||||
}
|
||||
activeOptionId = id
|
||||
markDirty()
|
||||
return id
|
||||
}
|
||||
|
||||
function updateOption(id: OptionID, patch: Partial<OptionDefinition>) {
|
||||
const opt = collection.options[id]
|
||||
if (!opt) return
|
||||
collection = {
|
||||
...collection,
|
||||
options: { ...collection.options, [id]: { ...opt, ...patch } },
|
||||
}
|
||||
markDirty()
|
||||
}
|
||||
|
||||
function deleteOption(id: OptionID) {
|
||||
const { [id]: _, ...rest } = collection.options
|
||||
collection = { ...collection, options: rest as Record<OptionID, OptionDefinition> }
|
||||
if (activeOptionId === id) activeOptionId = null
|
||||
markDirty()
|
||||
}
|
||||
|
||||
function duplicateOption(id: OptionID): OptionID {
|
||||
const opt = collection.options[id]
|
||||
if (!opt) return id
|
||||
const newId = uid('op') as OptionID
|
||||
const name = typeof opt.name === 'string'
|
||||
? opt.name + ' (copia)'
|
||||
: { ...(opt.name as any), es: ((opt.name as any).es ?? '') + ' (copia)' }
|
||||
collection = {
|
||||
...collection,
|
||||
options: { ...collection.options, [newId]: { ...opt, id: newId, code: opt.code + '_copy', name } },
|
||||
}
|
||||
activeOptionId = newId
|
||||
return newId
|
||||
}
|
||||
|
||||
// ── Entities ───────────────────────────────────────────────────────────────
|
||||
|
||||
function addEntity(): EntityID {
|
||||
const id = uid('en') as EntityID
|
||||
collection = {
|
||||
...collection,
|
||||
entities: { ...collection.entities, [id]: emptyEntity(id) },
|
||||
}
|
||||
activeEntityId = id
|
||||
activeGroupId = null
|
||||
activeAttrId = null
|
||||
markDirty()
|
||||
return id
|
||||
}
|
||||
|
||||
function updateEntity(id: EntityID, patch: Record<string, unknown>) {
|
||||
const entity = collection.entities[id]
|
||||
if (!entity) return
|
||||
collection = {
|
||||
...collection,
|
||||
entities: { ...collection.entities, [id]: { ...entity, ...patch } as Entity },
|
||||
}
|
||||
markDirty()
|
||||
}
|
||||
|
||||
function deleteEntity(id: EntityID) {
|
||||
const { [id]: _, ...rest } = collection.entities
|
||||
collection = { ...collection, entities: rest as Record<EntityID, Entity> }
|
||||
if (activeEntityId === id) setActiveEntity(null)
|
||||
markDirty()
|
||||
}
|
||||
|
||||
// ── Groups ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function addGroup(entityId: EntityID, kind: 'fixed' | 'optional' = 'fixed'): GroupID {
|
||||
const entity = collection.entities[entityId]
|
||||
if (!entity) return '' as GroupID
|
||||
const id = uid('gr') as GroupID
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
groups: { ...entity.groups, [id]: emptyGroup(id, kind) },
|
||||
},
|
||||
},
|
||||
}
|
||||
activeGroupId = id
|
||||
activeAttrId = null
|
||||
markDirty()
|
||||
return id
|
||||
}
|
||||
|
||||
function updateGroup(entityId: EntityID, groupId: GroupID, patch: Record<string, unknown>) {
|
||||
const entity = collection.entities[entityId]
|
||||
const group = entity?.groups[groupId]
|
||||
if (!entity || !group) return
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
groups: { ...entity.groups, [groupId]: { ...group, ...patch } as Group },
|
||||
},
|
||||
},
|
||||
}
|
||||
markDirty()
|
||||
}
|
||||
|
||||
function deleteGroup(entityId: EntityID, groupId: GroupID) {
|
||||
const entity = collection.entities[entityId]
|
||||
if (!entity) return
|
||||
const { [groupId]: _, ...rest } = entity.groups
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: { ...entity, groups: rest as Record<GroupID, Group> },
|
||||
},
|
||||
}
|
||||
if (activeGroupId === groupId) { activeGroupId = null; activeAttrId = null }
|
||||
markDirty()
|
||||
}
|
||||
|
||||
// ── Attributes ─────────────────────────────────────────────────────────────
|
||||
|
||||
function addAttribute(entityId: EntityID, groupId?: GroupID): AttrID {
|
||||
const id = uid('at') as AttrID
|
||||
const attr = emptyAttribute(id)
|
||||
|
||||
if (groupId) {
|
||||
const entity = collection.entities[entityId]
|
||||
const group = entity?.groups[groupId]
|
||||
if (entity && group) {
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
groups: {
|
||||
...entity.groups,
|
||||
[groupId]: { ...group, attributes: [...group.attributes, attr] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const entity = collection.entities[entityId]
|
||||
if (entity) {
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: { ...entity, attributes: [...entity.attributes, attr] },
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activeAttrId = id
|
||||
markDirty()
|
||||
return id
|
||||
}
|
||||
|
||||
function updateAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID, patch: Record<string, unknown>) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, attr => ({ ...attr, ...patch }) as Attribute)
|
||||
markDirty()
|
||||
}
|
||||
|
||||
function deleteAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, null)
|
||||
if (activeAttrId === attrId) activeAttrId = null
|
||||
markDirty()
|
||||
}
|
||||
|
||||
// ── Attribute options ──────────────────────────────────────────────────────
|
||||
|
||||
function addOptionToAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID, optionId: OptionID) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, attr => {
|
||||
if (attr.type === 'fixed' || attr.type === 'literal') return attr
|
||||
if (attr.options.some(o => o.optionId === optionId)) return attr
|
||||
const newOpt: AttributeOption = { optionId }
|
||||
const options = [...attr.options, newOpt]
|
||||
const updated = { ...attr, options }
|
||||
if (!attr.default && options.length === 1) (updated as any).default = optionId
|
||||
return updated
|
||||
})
|
||||
markDirty()
|
||||
}
|
||||
|
||||
function removeOptionFromAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID, optionId: OptionID) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, attr => {
|
||||
if (attr.type === 'fixed' || attr.type === 'literal' ) return attr
|
||||
const options = attr.options.filter(o => o.optionId !== optionId)
|
||||
const updated = { ...attr, options }
|
||||
if ((attr as any).default === optionId) (updated as any).default = options[0]?.optionId ?? ''
|
||||
return updated
|
||||
})
|
||||
markDirty()
|
||||
}
|
||||
|
||||
// ── Catalog attributes ─────────────────────────────────────────────────────
|
||||
|
||||
function addCatalogAttribute(): AttrID {
|
||||
const id = uid('at') as AttrID
|
||||
const attr = emptyLiteralAttribute(id)
|
||||
collection = { ...collection, attributes: [...collection.attributes, attr] }
|
||||
activeAttrId = id
|
||||
activeEntityId = null
|
||||
activeGroupId = null
|
||||
markDirty()
|
||||
return id
|
||||
}
|
||||
|
||||
function updateCatalogAttribute(attrId: AttrID, patch: Record<string, unknown>) {
|
||||
collection = {
|
||||
...collection,
|
||||
attributes: collection.attributes.map(a =>
|
||||
a.id === attrId ? { ...a, ...patch } as Attribute : a
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCatalogAttribute(attrId: AttrID) {
|
||||
collection = { ...collection, attributes: collection.attributes.filter(a => a.id !== attrId) }
|
||||
if (activeAttrId === attrId) activeAttrId = null
|
||||
}
|
||||
|
||||
// ── Rules ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function addRule(
|
||||
entityId: EntityID,
|
||||
groupId: GroupID | undefined,
|
||||
attrId: AttrID | undefined,
|
||||
rule: Rule,
|
||||
) {
|
||||
if (attrId) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, (a) => ({
|
||||
...a,
|
||||
rules: [...((a as any).rules ?? []), rule],
|
||||
}))
|
||||
} else if (groupId) {
|
||||
collection = produceGroup(collection, entityId, groupId, (g) => ({
|
||||
...g,
|
||||
rules: [...((g as any).rules ?? []), rule],
|
||||
}))
|
||||
} else {
|
||||
collection = produceEntity(collection, entityId, (e) => ({
|
||||
...e,
|
||||
rules: [...(e.rules ?? []), rule],
|
||||
}))
|
||||
}
|
||||
isDirty = true
|
||||
}
|
||||
|
||||
function updateRule(
|
||||
entityId: EntityID,
|
||||
groupId: GroupID | undefined,
|
||||
attrId: AttrID | undefined,
|
||||
ruleId: string,
|
||||
patch: Partial<Rule>,
|
||||
) {
|
||||
const mapRules = (rules: Rule[]) =>
|
||||
rules.map(r => (r.id === ruleId ? { ...r, ...patch } : r))
|
||||
|
||||
if (attrId) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, (a) => ({
|
||||
...a,
|
||||
rules: mapRules((a as any).rules ?? []),
|
||||
}))
|
||||
} else if (groupId) {
|
||||
collection = produceGroup(collection, entityId, groupId, (g) => ({
|
||||
...g,
|
||||
rules: mapRules((g as any).rules ?? []),
|
||||
}))
|
||||
} else {
|
||||
collection = produceEntity(collection, entityId, (e) => ({
|
||||
...e,
|
||||
rules: mapRules(e.rules ?? []),
|
||||
}))
|
||||
}
|
||||
isDirty = true
|
||||
}
|
||||
|
||||
function deleteRule(
|
||||
entityId: EntityID,
|
||||
groupId: GroupID | undefined,
|
||||
attrId: AttrID | undefined,
|
||||
ruleId: string,
|
||||
) {
|
||||
const filterRules = (rules: Rule[]) =>
|
||||
rules.filter(r => r.id !== ruleId)
|
||||
|
||||
if (attrId) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, (a) => ({
|
||||
...a,
|
||||
rules: filterRules((a as any).rules ?? []),
|
||||
}))
|
||||
} else if (groupId) {
|
||||
collection = produceGroup(collection, entityId, groupId, (g) => ({
|
||||
...g,
|
||||
rules: filterRules((g as any).rules ?? []),
|
||||
}))
|
||||
} else {
|
||||
collection = produceEntity(collection, entityId, (e) => ({
|
||||
...e,
|
||||
rules: filterRules(e.rules ?? []),
|
||||
}))
|
||||
}
|
||||
isDirty = true
|
||||
}
|
||||
|
||||
// ── Import / Export ────────────────────────────────────────────────────────
|
||||
|
||||
function exportToJson(): string {
|
||||
return JSON.stringify(collection, null, 2)
|
||||
}
|
||||
|
||||
function importFromJson(json: string) {
|
||||
const parsed = JSON.parse(json)
|
||||
collection = parsed
|
||||
activeEntityId = null
|
||||
activeGroupId = null
|
||||
activeAttrId = null
|
||||
activeOptionId = null
|
||||
isDirty = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
collection = emptyCollection()
|
||||
activeEntityId = null
|
||||
activeGroupId = null
|
||||
activeAttrId = null
|
||||
activeOptionId = null
|
||||
isDirty = false
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
// State (getters)
|
||||
get collection() { return collection },
|
||||
get validation() { return validation },
|
||||
get activePanel() { return activePanel },
|
||||
get isDirty() { return isDirty },
|
||||
get canPreview() { return canPreview },
|
||||
get activeEntityId() { return activeEntityId },
|
||||
get activeGroupId() { return activeGroupId },
|
||||
get activeAttrId() { return activeAttrId },
|
||||
get activeOptionId() { return activeOptionId },
|
||||
get activeEntity() { return activeEntity },
|
||||
get activeGroup() { return activeGroup },
|
||||
get activeAttribute() { return activeAttribute },
|
||||
get activeOption() { return activeOption },
|
||||
|
||||
// Navigation
|
||||
setPanel,
|
||||
setActiveEntity,
|
||||
setActiveGroup,
|
||||
setActiveAttribute,
|
||||
setActiveEntityAttribute,
|
||||
setActiveOption,
|
||||
|
||||
// Collection
|
||||
updateCollectionMeta,
|
||||
|
||||
// Options
|
||||
addOption,
|
||||
updateOption,
|
||||
deleteOption,
|
||||
duplicateOption,
|
||||
|
||||
// Entities
|
||||
addEntity,
|
||||
updateEntity,
|
||||
deleteEntity,
|
||||
|
||||
// Groups
|
||||
addGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
|
||||
// Attributes
|
||||
addAttribute,
|
||||
updateAttribute,
|
||||
deleteAttribute,
|
||||
addCatalogAttribute,
|
||||
updateCatalogAttribute,
|
||||
deleteCatalogAttribute,
|
||||
|
||||
// Attribute options
|
||||
addOptionToAttribute,
|
||||
removeOptionFromAttribute,
|
||||
|
||||
// Rules
|
||||
addRule,
|
||||
updateRule,
|
||||
deleteRule,
|
||||
|
||||
// Import/Export
|
||||
exportToJson,
|
||||
importFromJson,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export type EditorStore = ReturnType<typeof createEditorStore>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HELPERS INTERNOS — produce inmutable
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function produceAttr(
|
||||
collection: Collection,
|
||||
entityId: EntityID,
|
||||
groupId: GroupID | undefined,
|
||||
attrId: AttrID,
|
||||
fn: ((attr: Attribute) => Attribute) | null,
|
||||
): Collection {
|
||||
const entity = collection.entities[entityId]
|
||||
if (!entity) return collection
|
||||
|
||||
function mapAttrs(attrs: Attribute[]): Attribute[] {
|
||||
if (fn === null) return attrs.filter(a => a.id !== attrId)
|
||||
return attrs.map(a => a.id === attrId ? fn(a) as Attribute : a)
|
||||
}
|
||||
|
||||
if (groupId) {
|
||||
const group = entity.groups[groupId]
|
||||
if (!group) return collection
|
||||
return {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
groups: {
|
||||
...entity.groups,
|
||||
[groupId]: { ...group, attributes: mapAttrs(group.attributes) },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: { ...entity, attributes: mapAttrs(entity.attributes) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function produceEntity(
|
||||
collection: Collection,
|
||||
entityId: EntityID,
|
||||
fn: (entity: Entity) => Entity,
|
||||
): Collection {
|
||||
const entity = collection.entities[entityId]
|
||||
if (!entity) return collection
|
||||
return {
|
||||
...collection,
|
||||
entities: { ...collection.entities, [entityId]: fn(entity) },
|
||||
}
|
||||
}
|
||||
|
||||
function produceGroup(
|
||||
collection: Collection,
|
||||
entityId: EntityID,
|
||||
groupId: GroupID,
|
||||
fn: (group: Group) => Group,
|
||||
): Collection {
|
||||
const entity = collection.entities[entityId]
|
||||
if (!entity) return collection
|
||||
const group = entity.groups[groupId]
|
||||
if (!group) return collection
|
||||
return {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
groups: { ...entity.groups, [groupId]: fn(group) },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · Engine Implementation
|
||||
//
|
||||
// Implementación de ViceEngine.
|
||||
// Junta GacGraphBuilder, GacValidator, GacEngine y Session.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { Collection, EntityID } from './types.ts'
|
||||
import type { ViceEngine, ViceSession, LoadResult } from './engine_types.ts'
|
||||
import { GacGraphBuilder } from './graph_builder.ts'
|
||||
import { GacValidator } from './validator.ts'
|
||||
import { GacEngine } from './gac.ts'
|
||||
import { Session } from './session.ts'
|
||||
import type { GacGraph } from './gac.ts'
|
||||
import type {JsonLogic} from "@/tsat/lib/types";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// JSON LOGIC EVALUATOR
|
||||
// Implementación mínima — reemplazable por json-logic-js en producción.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function evalLogic(logic: JsonLogic, data: Record<string, unknown>): unknown {
|
||||
if (logic === null || typeof logic !== 'object') return logic
|
||||
if (Array.isArray(logic)) return logic
|
||||
|
||||
const obj = logic as Record<string, unknown>
|
||||
|
||||
if ('var' in obj) {
|
||||
const key = obj['var'] as string
|
||||
return key in data ? data[key] : null
|
||||
}
|
||||
if ('==' in obj) {
|
||||
const [a, b] = obj['=='] as [JsonLogic, JsonLogic]
|
||||
return evalLogic(a, data) === evalLogic(b, data)
|
||||
}
|
||||
if ('!=' in obj) {
|
||||
const [a, b] = obj['!='] as [JsonLogic, JsonLogic]
|
||||
return evalLogic(a, data) !== evalLogic(b, data)
|
||||
}
|
||||
if ('>' in obj) {
|
||||
const [a, b] = obj['>'] as [JsonLogic, JsonLogic]
|
||||
return (evalLogic(a, data) as number) > (evalLogic(b, data) as number)
|
||||
}
|
||||
if ('>=' in obj) {
|
||||
const [a, b] = obj['>='] as [JsonLogic, JsonLogic]
|
||||
return (evalLogic(a, data) as number) >= (evalLogic(b, data) as number)
|
||||
}
|
||||
if ('<' in obj) {
|
||||
const [a, b] = obj['<'] as [JsonLogic, JsonLogic]
|
||||
return (evalLogic(a, data) as number) < (evalLogic(b, data) as number)
|
||||
}
|
||||
if ('<=' in obj) {
|
||||
const [a, b] = obj['<='] as [JsonLogic, JsonLogic]
|
||||
return (evalLogic(a, data) as number) <= (evalLogic(b, data) as number)
|
||||
}
|
||||
if ('and' in obj) {
|
||||
return (obj['and'] as JsonLogic[]).every(c => Boolean(evalLogic(c, data)))
|
||||
}
|
||||
if ('or' in obj) {
|
||||
return (obj['or'] as JsonLogic[]).some(c => Boolean(evalLogic(c, data)))
|
||||
}
|
||||
if ('!' in obj) {
|
||||
return !evalLogic(obj['!'] as JsonLogic, data)
|
||||
}
|
||||
if ('in' in obj) {
|
||||
const [val, arr] = obj['in'] as [JsonLogic, JsonLogic]
|
||||
const resolved = evalLogic(arr, data)
|
||||
return Array.isArray(resolved) && resolved.includes(evalLogic(val, data))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ENGINE IMPL
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class ViceEngineImpl implements ViceEngine {
|
||||
|
||||
private gacEngine = new GacEngine(evalLogic)
|
||||
private graph: GacGraph | null = null
|
||||
private result: LoadResult | null = null
|
||||
|
||||
load(collection: Collection, entityId: EntityID): LoadResult {
|
||||
const graph = new GacGraphBuilder().build(collection, entityId)
|
||||
const validator = new GacValidator()
|
||||
const validation = validator.validate(graph)
|
||||
|
||||
this.graph = graph
|
||||
this.result = {
|
||||
valid: validation.valid,
|
||||
issues: validation.issues,
|
||||
entityId,
|
||||
}
|
||||
|
||||
return this.result
|
||||
}
|
||||
|
||||
createSession(): ViceSession {
|
||||
if (!this.graph) throw new Error('[VICE] No graph loaded — call load() first')
|
||||
if (!this.result) throw new Error('[VICE] No result — call load() first')
|
||||
if (!this.result.valid) {
|
||||
const errors = this.result.issues
|
||||
.filter(i => i.severity === 'error')
|
||||
.map(i => i.message)
|
||||
.join('\n')
|
||||
throw new Error(`[VICE] Cannot create session — graph has errors:\n${errors}`)
|
||||
}
|
||||
|
||||
return new Session(this.graph, this.gacEngine)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FACTORY
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createViceEngine(): ViceEngine {
|
||||
return new ViceEngineImpl()
|
||||
}
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · Engine — API pública
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { Collection, EntityID, OptionID, EntityPathKey } from './types.ts'
|
||||
import type { ValidationResult } from './validator.ts'
|
||||
import type { SessionState, SelectResult, DeselectResult } from './state_types.ts'
|
||||
|
||||
export interface LoadResult {
|
||||
valid: boolean
|
||||
issues: ValidationResult['issues']
|
||||
entityId: EntityID
|
||||
}
|
||||
|
||||
export interface ViceSession {
|
||||
select(path: EntityPathKey, optionId: OptionID, quantity?: number): SelectResult
|
||||
deselect(path: EntityPathKey): DeselectResult
|
||||
getState(): SessionState
|
||||
reset(): SessionState
|
||||
}
|
||||
|
||||
export interface ViceEngine {
|
||||
load(collection: Collection, entityId: EntityID): LoadResult
|
||||
createSession(): ViceSession
|
||||
}
|
||||
|
||||
export type { ViceEngine as default }
|
||||
@ -1,343 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · GAC (Generalized Arc Consistency)
|
||||
//
|
||||
// Responsabilidades:
|
||||
// - Definir las estructuras del grafo (GacGraph, GacState)
|
||||
// - Propagar cambios de dominio mediante GAC
|
||||
// - Devolver el estado arc-consistent resultante
|
||||
//
|
||||
// No conoce Collection, Session ni ningún tipo de runtime externo.
|
||||
// Opera exclusivamente sobre GacGraph y GacState.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { LingString } from '@/ling/lib'
|
||||
import type {
|
||||
AttrID,
|
||||
GroupID,
|
||||
EntityID,
|
||||
OptionID,
|
||||
EntityPathKey,
|
||||
RuleAction,
|
||||
AttributeType,
|
||||
Severity,
|
||||
OptionSelection,
|
||||
} from './types.ts'
|
||||
import type {JsonLogic} from "@/jslg/lib";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VARIABLE
|
||||
// Un atributo en el grafo. Su dominio se reduce por GAC.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GacVariable {
|
||||
path: EntityPathKey
|
||||
attrId: AttrID
|
||||
type: AttributeType
|
||||
fullDomain: ReadonlySet<OptionID> // dominio del catálogo — nunca muta
|
||||
defaultOption: OptionID
|
||||
dependants: EntityPathKey[] // paths de constraints que dependen de esta variable
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GROUP
|
||||
// Un grupo en el grafo. Solo importa su kind para saber el estado inicial.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GacGroup {
|
||||
path: EntityPathKey
|
||||
groupId: GroupID
|
||||
kind: 'fixed' | 'optional'
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CONSTRAINT
|
||||
// Una regla compilada y lista para evaluar. Inmutable.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GacConstraint {
|
||||
id: string
|
||||
scope: EntityPathKey
|
||||
dependsOn: EntityPathKey[] // variables que condition referencia
|
||||
condition: JsonLogic | null // null = siempre activa
|
||||
action: RuleAction
|
||||
message: LingString
|
||||
severity: Severity
|
||||
mode: 'after' | 'override'
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GAC GRAPH
|
||||
// Construido una vez en load(). Inmutable en runtime.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GacGraph {
|
||||
entityId: EntityID
|
||||
variables: Map<EntityPathKey, GacVariable>
|
||||
groups: Map<EntityPathKey, GacGroup>
|
||||
constraints: Map<string, GacConstraint>
|
||||
/** Índice inverso: variable/group → IDs de constraints que la referencian en dependsOn */
|
||||
dependantIndex: Map<EntityPathKey, Set<string>>
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GAC STATE
|
||||
// Copia mutable por sesión. Nunca muta GacGraph.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GacVariableState {
|
||||
path: EntityPathKey
|
||||
domain: Set<OptionID> // dominio reducido por GAC
|
||||
current: OptionSelection | null
|
||||
active: boolean
|
||||
violations: GacViolation[]
|
||||
}
|
||||
|
||||
export interface GacGroupState {
|
||||
path: EntityPathKey
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface GacViolation {
|
||||
constraintId: string
|
||||
optionId?: OptionID
|
||||
reason: LingString
|
||||
severity: Severity
|
||||
}
|
||||
|
||||
export interface GacState {
|
||||
variables: Map<EntityPathKey, GacVariableState>
|
||||
groups: Map<EntityPathKey, GacGroupState>
|
||||
consistent: boolean
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GAC RESULT
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GacResult {
|
||||
state: GacState
|
||||
consistent: boolean
|
||||
active: Set<string> // IDs de constraints activos en esta propagación
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// JSON LOGIC EVALUATOR
|
||||
// Inyectado desde fuera — el motor no depende de la librería directamente.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type JsonLogicEvaluator = (logic: JsonLogic, data: Record<string, unknown>) => unknown
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GAC ENGINE
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class GacEngine {
|
||||
|
||||
constructor(private readonly evalLogic: JsonLogicEvaluator) {}
|
||||
|
||||
// ── Propagación ─────────────────────────────────────────────────────────────
|
||||
|
||||
propagate(graph: GacGraph, state: GacState): GacResult {
|
||||
const active = new Set<string>()
|
||||
const queue = new Set<string>(graph.constraints.keys())
|
||||
|
||||
while (queue.size > 0) {
|
||||
const cid = this.dequeue(queue)
|
||||
const constraint = graph.constraints.get(cid)
|
||||
if (!constraint) continue
|
||||
|
||||
if (!this.evalCondition(constraint, state)) continue
|
||||
|
||||
active.add(cid)
|
||||
|
||||
const reduced = this.applyAction(constraint, state, graph)
|
||||
const targetPath = this.resolveTarget(constraint, graph)
|
||||
|
||||
if (reduced && targetPath) {
|
||||
for (const dep of graph.dependantIndex.get(targetPath) ?? new Set()) {
|
||||
queue.add(dep)
|
||||
}
|
||||
}
|
||||
|
||||
// dominio vacío = estado inconsistente
|
||||
if (targetPath) {
|
||||
const vs = state.variables.get(targetPath)
|
||||
if (vs && vs.domain.size === 0) state.consistent = false
|
||||
}
|
||||
}
|
||||
|
||||
return { state, consistent: state.consistent, active }
|
||||
}
|
||||
|
||||
// ── Evaluación de condición ──────────────────────────────────────────────────
|
||||
|
||||
private evalCondition(constraint: GacConstraint, state: GacState): boolean {
|
||||
if (!constraint.condition) return true
|
||||
const data = this.buildContext(constraint.dependsOn, state)
|
||||
return Boolean(this.evalLogic(constraint.condition, data))
|
||||
}
|
||||
|
||||
private buildContext(paths: EntityPathKey[], state: GacState): Record<string, unknown> {
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const path of paths) {
|
||||
const vs = state.variables.get(path)
|
||||
if (!vs) continue
|
||||
const attrId = path.split('/').pop() ?? path
|
||||
data[attrId] = vs.current?.optionId ?? null
|
||||
if (vs.current?.quantity !== undefined) {
|
||||
data[`${attrId}:quantity`] = vs.current.quantity
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ── Aplicación de acción ─────────────────────────────────────────────────────
|
||||
|
||||
private applyAction(constraint: GacConstraint, state: GacState, graph: GacGraph): boolean {
|
||||
const targetPath = this.resolveTarget(constraint, graph)
|
||||
if (!targetPath) return false
|
||||
|
||||
const { type, values } = constraint.action
|
||||
|
||||
// activate / deactivate pueden apuntar a grupo o atributo opcional
|
||||
if (type === 'activate' || type === 'deactivate') {
|
||||
const gs = state.groups.get(targetPath)
|
||||
if (gs) {
|
||||
const before = gs.active
|
||||
gs.active = type === 'activate'
|
||||
return gs.active !== before
|
||||
}
|
||||
const vs = state.variables.get(targetPath)
|
||||
if (!vs) return false
|
||||
const before = vs.active
|
||||
vs.active = type === 'activate'
|
||||
if (!vs.active) vs.domain.clear()
|
||||
return vs.active !== before
|
||||
}
|
||||
|
||||
const vs = state.variables.get(targetPath)
|
||||
if (!vs) return false
|
||||
|
||||
const before = vs.domain.size
|
||||
|
||||
switch (type) {
|
||||
case 'forbid': {
|
||||
for (const optId of values ?? []) {
|
||||
vs.domain.delete(optId)
|
||||
if (vs.current?.optionId === optId) {
|
||||
vs.violations.push({ constraintId: constraint.id, optionId: optId, reason: constraint.message, severity: constraint.severity })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'allow': {
|
||||
const allowed = new Set(values ?? [])
|
||||
for (const optId of [...vs.domain]) {
|
||||
if (!allowed.has(optId)) {
|
||||
vs.domain.delete(optId)
|
||||
if (vs.current?.optionId === optId) {
|
||||
vs.violations.push({ constraintId: constraint.id, optionId: optId, reason: constraint.message, severity: constraint.severity })
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'require': {
|
||||
if (!vs.current) {
|
||||
vs.violations.push({ constraintId: constraint.id, reason: constraint.message, severity: constraint.severity })
|
||||
}
|
||||
if ((values?.length ?? 0) > 0) {
|
||||
const required = new Set(values)
|
||||
if (vs.current && !required.has(vs.current.optionId)) {
|
||||
vs.violations.push({ constraintId: constraint.id, optionId: vs.current.optionId, reason: constraint.message, severity: constraint.severity })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'suggest': {
|
||||
const suggested = new Set(values ?? [])
|
||||
if (vs.current && !suggested.has(vs.current.optionId)) {
|
||||
vs.violations.push({ constraintId: constraint.id, optionId: vs.current.optionId, reason: constraint.message, severity: 'info' })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return vs.domain.size < before
|
||||
}
|
||||
|
||||
// ── resolveTarget ────────────────────────────────────────────────────────────
|
||||
|
||||
private resolveTarget(constraint: GacConstraint, graph: GacGraph): EntityPathKey | null {
|
||||
const target = constraint.action.target
|
||||
if (!target) return null
|
||||
for (const path of graph.variables.keys()) {
|
||||
if (path.endsWith(`/${target}`) || path === target) return path
|
||||
}
|
||||
for (const path of graph.groups.keys()) {
|
||||
if (path.endsWith(`/${target}`) || path === target) return path
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private dequeue(queue: Set<string>): string {
|
||||
const first = queue.values().next().value as string
|
||||
queue.delete(first)
|
||||
return first
|
||||
}
|
||||
|
||||
// ── Estado ───────────────────────────────────────────────────────────────────
|
||||
|
||||
initState(graph: GacGraph): GacState {
|
||||
const variables = new Map<EntityPathKey, GacVariableState>()
|
||||
const groups = new Map<EntityPathKey, GacGroupState>()
|
||||
|
||||
for (const [path, v] of graph.variables) {
|
||||
variables.set(path, {
|
||||
path,
|
||||
domain: new Set(v.fullDomain),
|
||||
current: v.type === 'required' ? { optionId: v.defaultOption } : null,
|
||||
active: v.type !== 'optional',
|
||||
violations: [],
|
||||
})
|
||||
}
|
||||
|
||||
for (const [path, g] of graph.groups) {
|
||||
groups.set(path, { path, active: g.kind === 'fixed' })
|
||||
}
|
||||
|
||||
return { variables, groups, consistent: true }
|
||||
}
|
||||
|
||||
applySelection(state: GacState, path: EntityPathKey, optionId: OptionID, quantity?: number): GacState {
|
||||
const next = this.cloneState(state)
|
||||
const vs = next.variables.get(path)
|
||||
if (!vs) return next
|
||||
vs.current = { optionId, ...(quantity !== undefined ? { quantity } : {}) }
|
||||
vs.active = true
|
||||
vs.violations = []
|
||||
return next
|
||||
}
|
||||
|
||||
applyDeselection(state: GacState, path: EntityPathKey): GacState {
|
||||
const next = this.cloneState(state)
|
||||
const vs = next.variables.get(path)
|
||||
if (!vs) return next
|
||||
vs.current = null
|
||||
vs.active = false
|
||||
vs.violations = []
|
||||
return next
|
||||
}
|
||||
|
||||
cloneState(state: GacState): GacState {
|
||||
const variables = new Map<EntityPathKey, GacVariableState>()
|
||||
const groups = new Map<EntityPathKey, GacGroupState>()
|
||||
for (const [path, v] of state.variables) {
|
||||
variables.set(path, { ...v, domain: new Set(v.domain), violations: [...v.violations] })
|
||||
}
|
||||
for (const [path, g] of state.groups) {
|
||||
groups.set(path, { ...g })
|
||||
}
|
||||
return { variables, groups, consistent: state.consistent }
|
||||
}
|
||||
}
|
||||
@ -1,252 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · Graph Builder
|
||||
//
|
||||
// Construye el GacGraph desde el modelo del catálogo.
|
||||
// Se ejecuta una vez en load() — el grafo es inmutable en runtime.
|
||||
//
|
||||
// Responsabilidades:
|
||||
// - Recorrer Collection → Entity → Group → Attribute → Rule
|
||||
// - Crear GacVariable por cada Attribute
|
||||
// - Crear GacGroup por cada Group
|
||||
// - Crear GacConstraint por cada Rule en cualquier nivel
|
||||
// - Inferir Rule.dependsOn desde JsonLogic si no está declarado
|
||||
// - Construir dependantIndex (grafo inverso)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type {
|
||||
Collection,
|
||||
Entity,
|
||||
Group,
|
||||
Attribute,
|
||||
Rule,
|
||||
EntityID,
|
||||
GroupID,
|
||||
EntityPath,
|
||||
EntityPathKey,
|
||||
} from './types.ts'
|
||||
|
||||
import type {
|
||||
GacGraph,
|
||||
GacVariable,
|
||||
GacGroup,
|
||||
GacConstraint,
|
||||
} from './gac.ts'
|
||||
import type {JsonLogic} from "@/tsat/lib/types";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HELPERS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function toKey(path: EntityPath): EntityPathKey {
|
||||
return path.join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae las referencias { var: 'at_xxx' } de un JsonLogic.
|
||||
* Usado para inferir Rule.dependsOn cuando no está declarado.
|
||||
*/
|
||||
function extractVars(logic: JsonLogic, found: Set<string> = new Set()): Set<string> {
|
||||
if (logic === null || typeof logic !== 'object') return found
|
||||
if (Array.isArray(logic)) {
|
||||
for (const item of logic) extractVars(item as JsonLogic, found)
|
||||
return found
|
||||
}
|
||||
const obj = logic as Record<string, unknown>
|
||||
if ('var' in obj && typeof obj['var'] === 'string') {
|
||||
found.add(obj['var'])
|
||||
return found
|
||||
}
|
||||
for (const val of Object.values(obj)) {
|
||||
extractVars(val as JsonLogic, found)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BUILDER
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class GacGraphBuilder {
|
||||
|
||||
private variables = new Map<EntityPathKey, GacVariable>()
|
||||
private groups = new Map<EntityPathKey, GacGroup>()
|
||||
private constraints = new Map<string, GacConstraint>()
|
||||
private constraintSeq = 0
|
||||
|
||||
build(collection: Collection, entityId: EntityID): GacGraph {
|
||||
const entity = collection.entities[entityId]
|
||||
if (!entity) throw new Error(`[VICE] Entity "${entityId}" not found`)
|
||||
|
||||
// 1 — Atributos globales del catálogo (contexto — fuente de constraints)
|
||||
for (const attr of collection.attributes) {
|
||||
this.registerVariable(attr, [attr.id])
|
||||
}
|
||||
|
||||
// 2 — Atributos globales de la entidad
|
||||
for (const attr of entity.attributes) {
|
||||
this.registerVariable(attr, [entityId, attr.id])
|
||||
}
|
||||
|
||||
// 3 — Reglas de la entidad
|
||||
for (const rule of entity.rules ?? []) {
|
||||
this.registerConstraint(rule, [entityId])
|
||||
}
|
||||
|
||||
// 4 — Grupos y sus atributos
|
||||
for (const [groupId, group] of Object.entries(entity.groups) as [GroupID, Group][]) {
|
||||
const groupPath: EntityPath = [entityId, groupId]
|
||||
|
||||
// Registrar grupo
|
||||
this.groups.set(toKey(groupPath), {
|
||||
path: toKey(groupPath),
|
||||
groupId: groupId as GroupID,
|
||||
kind: group.kind,
|
||||
})
|
||||
|
||||
// Atributos del grupo
|
||||
for (const attr of group.attributes) {
|
||||
this.registerVariable(attr, [entityId, groupId, attr.id])
|
||||
}
|
||||
|
||||
// Reglas del grupo (solo OptionalGroup)
|
||||
if (group.kind === 'optional') {
|
||||
for (const rule of group.rules ?? []) {
|
||||
this.registerConstraint(rule, groupPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5 — Inferir dependsOn desde JsonLogic donde no está declarado
|
||||
this.resolveInferredDependsOn()
|
||||
|
||||
// 6 — Construir dependantIndex
|
||||
const dependantIndex = this.buildDependantIndex()
|
||||
|
||||
return {
|
||||
entityId,
|
||||
variables: this.variables,
|
||||
groups: this.groups,
|
||||
constraints: this.constraints,
|
||||
dependantIndex,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registro de variables ──────────────────────────────────────────────────
|
||||
|
||||
private registerVariable(attr: Attribute, path: EntityPath): void {
|
||||
const key = toKey(path)
|
||||
|
||||
const fullDomain = this.extractDomain(attr)
|
||||
const defaultOption = this.extractDefault(attr)
|
||||
|
||||
this.variables.set(key, {
|
||||
path : key,
|
||||
attrId: attr.id,
|
||||
type: attr.type,
|
||||
fullDomain: new Set(fullDomain),
|
||||
defaultOption,
|
||||
dependants: (attr.dependants ?? []).map(toKey),
|
||||
})
|
||||
|
||||
// Reglas del atributo
|
||||
const rules = attr.type !== 'fixed' ? (attr.rules ?? []) : []
|
||||
for (const rule of rules) {
|
||||
this.registerConstraint(rule, path)
|
||||
}
|
||||
|
||||
// Reglas de las opciones
|
||||
if (attr.type === 'optional' || attr.type === 'required') {
|
||||
for (const attrOption of attr.options) {
|
||||
for (const rule of attrOption.rules ?? []) {
|
||||
this.registerConstraint(rule, [...path, attrOption.optionId])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registro de constraints ────────────────────────────────────────────────
|
||||
|
||||
private registerConstraint(rule: Rule, scope: EntityPath): void {
|
||||
const id = rule.id ?? (`cn_auto_${++this.constraintSeq}` as any)
|
||||
|
||||
const dependsOn = rule.dependsOn
|
||||
? rule.dependsOn.map(toKey)
|
||||
: [] // se infiere en resolveInferredDependsOn()
|
||||
|
||||
this.constraints.set(id, {
|
||||
id,
|
||||
scope: toKey(scope),
|
||||
dependsOn,
|
||||
condition: rule.condition ?? null,
|
||||
action: rule.action,
|
||||
message: rule.message,
|
||||
severity: rule.severity,
|
||||
mode: rule.mode ?? 'after',
|
||||
})
|
||||
}
|
||||
|
||||
// ── Inferencia de dependsOn ────────────────────────────────────────────────
|
||||
|
||||
private resolveInferredDependsOn(): void {
|
||||
for (const [id, constraint] of this.constraints) {
|
||||
if (constraint.dependsOn.length > 0) continue // ya declarado
|
||||
if (!constraint.condition) continue // sin condición = sin dependencias
|
||||
|
||||
const varNames = extractVars(constraint.condition)
|
||||
const resolved: EntityPathKey[] = []
|
||||
|
||||
for (const varName of varNames) {
|
||||
// Buscar variable cuyo attrId coincide con varName
|
||||
for (const [key, variable] of this.variables) {
|
||||
if (variable.attrId === varName) {
|
||||
resolved.push(key)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.constraints.set(id, { ...constraint, dependsOn: resolved })
|
||||
}
|
||||
}
|
||||
|
||||
// ── dependantIndex ─────────────────────────────────────────────────────────
|
||||
|
||||
private buildDependantIndex(): Map<EntityPathKey, Set<string>> {
|
||||
const index = new Map<EntityPathKey, Set<string>>()
|
||||
|
||||
// Desde dependsOn de cada constraint
|
||||
for (const [constraintId, constraint] of this.constraints) {
|
||||
for (const varPath of constraint.dependsOn) {
|
||||
if (!index.has(varPath)) index.set(varPath, new Set())
|
||||
index.get(varPath)!.add(constraintId)
|
||||
}
|
||||
}
|
||||
|
||||
// Desde dependants declarados en las variables
|
||||
for (const [varPath, variable] of this.variables) {
|
||||
for (const dep of variable.dependants) {
|
||||
if (!index.has(varPath)) index.set(varPath, new Set())
|
||||
index.get(varPath)!.add(dep)
|
||||
}
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
// ── Helpers de extracción ──────────────────────────────────────────────────
|
||||
|
||||
private extractDomain(attr: Attribute) {
|
||||
if (attr.type === 'fixed') return [attr.option.optionId]
|
||||
if (attr.type === 'optional') return attr.options.map(o => o.optionId)
|
||||
if (attr.type === 'required') return attr.options.map(o => o.optionId)
|
||||
return []
|
||||
}
|
||||
|
||||
private extractDefault(attr: Attribute) {
|
||||
if (attr.type === 'fixed') return attr.option.optionId
|
||||
if (attr.type === 'optional') return attr.default
|
||||
if (attr.type === 'required') return attr.default
|
||||
const _: never = attr
|
||||
throw new Error('[VICE] Unknown attribute type')
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
|
||||
|
||||
export * from './types.ts';
|
||||
export * from './engine_types.ts';
|
||||
export * from './state_types.ts'
|
||||
export * from './validator_types.ts';
|
||||
|
||||
|
||||
@ -1,121 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · Session
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type {OptionID, EntityPathKey} from './types.ts'
|
||||
import type {GacGraph, GacState} from './gac.ts'
|
||||
import type {SessionState, PropagationResult, SelectResult, DeselectResult} from './state_types.ts'
|
||||
import type {ViceSession} from './engine_types.ts'
|
||||
import {GacEngine} from './gac.ts'
|
||||
|
||||
|
||||
export class Session implements ViceSession {
|
||||
|
||||
private state: GacState
|
||||
|
||||
constructor(
|
||||
private readonly graph: GacGraph,
|
||||
private readonly engine: GacEngine,
|
||||
) {
|
||||
this.state = engine.initState(graph)
|
||||
this.state = engine.propagate(graph, this.state).state
|
||||
}
|
||||
|
||||
select(path: EntityPathKey, optionId: OptionID, quantity?: number): SelectResult {
|
||||
const vs = this.state.variables.get(path)
|
||||
if (!vs) return {ok: false, reason: 'INVALID_PATH'}
|
||||
if (!vs.active) return {ok: false, reason: 'INACTIVE_ATTRIBUTE'}
|
||||
if (!vs.domain.has(optionId)) return {ok: false, reason: 'OPTION_NOT_IN_DOMAIN'}
|
||||
|
||||
const prev = this.state
|
||||
const next = this.engine.applySelection(this.state, path, optionId, quantity)
|
||||
const result = this.engine.propagate(this.graph, next)
|
||||
this.state = result.state
|
||||
|
||||
return {ok: true, result: this.toPropagationResult(result.state, prev)}
|
||||
}
|
||||
|
||||
deselect(path: EntityPathKey): DeselectResult {
|
||||
const vs = this.state.variables.get(path)
|
||||
if (!vs) return {ok: false, reason: 'INVALID_PATH'}
|
||||
if (!vs.active) return {ok: false, reason: 'INACTIVE_ATTRIBUTE'}
|
||||
|
||||
const prev = this.state
|
||||
const next = this.engine.applyDeselection(this.state, path)
|
||||
const result = this.engine.propagate(this.graph, next)
|
||||
this.state = result.state
|
||||
|
||||
return {ok: true, result: this.toPropagationResult(result.state, prev)}
|
||||
}
|
||||
|
||||
getState(): SessionState {
|
||||
return this.toSessionState(this.state)
|
||||
}
|
||||
|
||||
reset(): SessionState {
|
||||
this.state = this.engine.initState(this.graph)
|
||||
this.state = this.engine.propagate(this.graph, this.state).state
|
||||
return this.toSessionState(this.state)
|
||||
}
|
||||
|
||||
private toSessionState(state: GacState): SessionState {
|
||||
const attributes = new Map<EntityPathKey, import('./state_types.ts').AttributeState>()
|
||||
const groups = new Map<EntityPathKey, import('./state_types.ts').GroupState>()
|
||||
|
||||
for (const [path, vs] of state.variables) {
|
||||
const variable = this.graph.variables.get(path)
|
||||
if (!variable) continue
|
||||
attributes.set(path, {
|
||||
path,
|
||||
attrId: variable.attrId,
|
||||
active: vs.active,
|
||||
domain: vs.domain,
|
||||
current: vs.current,
|
||||
violations: vs.violations.map(v => ({
|
||||
constraintId: v.constraintId,
|
||||
optionId: v.optionId,
|
||||
reason: v.reason,
|
||||
severity: v.severity,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
for (const [path, gs] of state.groups) {
|
||||
const group = this.graph.groups.get(path)
|
||||
if (!group) continue
|
||||
groups.set(path, {path, groupId: group.groupId, active: gs.active})
|
||||
}
|
||||
|
||||
return {entityId: this.graph.entityId, attributes, groups, consistent: state.consistent}
|
||||
}
|
||||
|
||||
private toPropagationResult(next: GacState, prev: GacState): PropagationResult {
|
||||
const changed = new Set<EntityPathKey>()
|
||||
|
||||
for (const [path, vs] of next.variables) {
|
||||
const prevVs = prev.variables.get(path)
|
||||
if (!prevVs) {
|
||||
changed.add(path);
|
||||
continue
|
||||
}
|
||||
if (
|
||||
vs.active !== prevVs.active ||
|
||||
vs.current?.optionId !== prevVs.current?.optionId ||
|
||||
!setsEqual(vs.domain, prevVs.domain)
|
||||
) changed.add(path)
|
||||
}
|
||||
|
||||
for (const [path, gs] of next.groups) {
|
||||
const prevGs = prev.groups.get(path)
|
||||
if (!prevGs || gs.active !== prevGs.active) changed.add(path)
|
||||
}
|
||||
|
||||
return {state: this.toSessionState(next), consistent: next.consistent, changed}
|
||||
}
|
||||
}
|
||||
|
||||
function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
|
||||
if (a.size !== b.size) return false
|
||||
for (const v of a) if (!b.has(v)) return false
|
||||
return true
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · Runtime State
|
||||
//
|
||||
// Tipos del estado en runtime expuestos hacia fuera.
|
||||
// Son la traducción del GacState interno al contrato público de la sesión.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { LingString } from '@/ling/lib'
|
||||
import type {
|
||||
AttrID,
|
||||
GroupID,
|
||||
OptionID,
|
||||
EntityPathKey,
|
||||
Severity,
|
||||
OptionSelection,
|
||||
} from './types.ts'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VIOLATION
|
||||
// Una restricción activa sobre un atributo o una opción concreta.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Violation {
|
||||
constraintId: string
|
||||
optionId?: OptionID // undefined = violación sobre el atributo completo
|
||||
reason: LingString
|
||||
severity: Severity
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ATTRIBUTE STATE
|
||||
// Estado de un atributo en un momento dado.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AttributeState {
|
||||
path: EntityPathKey
|
||||
attrId: AttrID
|
||||
active: boolean
|
||||
domain: ReadonlySet<OptionID> // opciones disponibles tras GAC
|
||||
current: OptionSelection | null
|
||||
violations: Violation[]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GROUP STATE
|
||||
// Estado de un grupo en un momento dado.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GroupState {
|
||||
path: EntityPathKey
|
||||
groupId: GroupID
|
||||
active: boolean
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SESSION STATE
|
||||
// Snapshot completo de una sesión — serializable.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SessionState {
|
||||
entityId: string
|
||||
attributes: Map<EntityPathKey, AttributeState>
|
||||
groups: Map<EntityPathKey, GroupState>
|
||||
consistent: boolean
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PROPAGATION RESULT
|
||||
// Lo que devuelve cada select() — qué cambió.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PropagationResult {
|
||||
state: SessionState
|
||||
consistent: boolean
|
||||
/** Paths de atributos cuyo dominio o estado cambió en esta propagación */
|
||||
changed: Set<EntityPathKey>
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SELECT / DESELECT RESULT
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SelectErrorReason =
|
||||
| 'INVALID_PATH'
|
||||
| 'INACTIVE_ATTRIBUTE'
|
||||
| 'OPTION_NOT_IN_DOMAIN'
|
||||
|
||||
export type SelectResult =
|
||||
| { ok: true; result: PropagationResult }
|
||||
| { ok: false; reason: SelectErrorReason }
|
||||
|
||||
export type DeselectResult =
|
||||
| { ok: true; result: PropagationResult }
|
||||
| { ok: false; reason: 'INVALID_PATH' | 'INACTIVE_ATTRIBUTE' }
|
||||
@ -1,164 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · Validator
|
||||
//
|
||||
// Validación design time del GacGraph.
|
||||
// Se ejecuta después de GacGraphBuilder.build() en load().
|
||||
// Solo opera sobre GacGraph — no conoce Collection.
|
||||
//
|
||||
// Errores (bloquean la sesión):
|
||||
// - MISSING_REFERENCE — target o dependsOn inexistente en el grafo
|
||||
// - INVALID_DEFAULT — default no está en el dominio
|
||||
// - EMPTY_DOMAIN — atributo sin opciones
|
||||
// - DUPLICATE_ID — ID repetido en el mismo scope
|
||||
//
|
||||
// Warnings (no bloquean):
|
||||
// - DEAD_OPTION — opción prohibida incondicionalmente
|
||||
// - REDUNDANT_RULE — regla que nunca puede activarse
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { GacGraph } from './gac.ts'
|
||||
import type {DesignIssue, ValidationResult} from "@/vice/libs/validator_types.ts";
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VALIDATOR
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class GacValidator {
|
||||
|
||||
validate(graph: GacGraph): ValidationResult {
|
||||
const issues: DesignIssue[] = []
|
||||
|
||||
this.checkMissingReferences(graph, issues)
|
||||
this.checkInvalidDefaults(graph, issues)
|
||||
this.checkEmptyDomains(graph, issues)
|
||||
this.checkDeadOptions(graph, issues)
|
||||
this.checkRedundantRules(graph, issues)
|
||||
|
||||
return {
|
||||
valid: !issues.some(i => i.severity === 'error'),
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
// ── MISSING_REFERENCE ──────────────────────────────────────────────────────
|
||||
|
||||
private checkMissingReferences(graph: GacGraph, issues: DesignIssue[]): void {
|
||||
for (const constraint of graph.constraints.values()) {
|
||||
|
||||
// target debe existir como variable o grupo
|
||||
const tid = constraint.action.target
|
||||
if (tid) {
|
||||
const found =
|
||||
[...graph.variables.keys()].some(k => k.endsWith(`/${tid}`) || k === tid) ||
|
||||
[...graph.groups.keys()].some(k => k.endsWith(`/${tid}`) || k === tid)
|
||||
|
||||
if (!found) {
|
||||
issues.push({
|
||||
code: 'MISSING_REFERENCE',
|
||||
severity: 'error',
|
||||
message: `Rule "${constraint.id}" references unknown target: "${tid}"`,
|
||||
path: constraint.scope,
|
||||
ruleId: constraint.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// dependsOn deben existir como variables
|
||||
for (const dep of constraint.dependsOn) {
|
||||
if (!graph.variables.has(dep)) {
|
||||
issues.push({
|
||||
code: 'MISSING_REFERENCE',
|
||||
severity: 'error',
|
||||
message: `Rule "${constraint.id}" references unknown dependency: "${dep}"`,
|
||||
path: constraint.scope,
|
||||
ruleId: constraint.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── INVALID_DEFAULT ────────────────────────────────────────────────────────
|
||||
|
||||
private checkInvalidDefaults(graph: GacGraph, issues: DesignIssue[]): void {
|
||||
for (const v of graph.variables.values()) {
|
||||
if (!v.fullDomain.has(v.defaultOption)) {
|
||||
issues.push({
|
||||
code: 'INVALID_DEFAULT',
|
||||
severity: 'error',
|
||||
message: `Attribute "${v.attrId}" default "${v.defaultOption}" is not in its domain`,
|
||||
path: v.path,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── EMPTY_DOMAIN ───────────────────────────────────────────────────────────
|
||||
|
||||
private checkEmptyDomains(graph: GacGraph, issues: DesignIssue[]): void {
|
||||
for (const v of graph.variables.values()) {
|
||||
if (v.fullDomain.size === 0) {
|
||||
issues.push({
|
||||
code: 'EMPTY_DOMAIN',
|
||||
severity: 'error',
|
||||
message: `Attribute "${v.attrId}" has no options`,
|
||||
path: v.path,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DEAD_OPTION ────────────────────────────────────────────────────────────
|
||||
// Una opción está muerta si una regla incondicional la prohíbe siempre.
|
||||
|
||||
private checkDeadOptions(graph: GacGraph, issues: DesignIssue[]): void {
|
||||
for (const v of graph.variables.values()) {
|
||||
for (const optionId of v.fullDomain) {
|
||||
const dead = [...graph.constraints.values()].some(c => {
|
||||
const tid = c.action.target
|
||||
if (!tid) return false
|
||||
const targetsThis = v.path.endsWith(`/${tid}`) || v.path === tid
|
||||
return targetsThis &&
|
||||
!c.condition &&
|
||||
c.action.type === 'forbid' &&
|
||||
c.action.values?.includes(optionId)
|
||||
})
|
||||
|
||||
if (dead) {
|
||||
issues.push({
|
||||
code: 'DEAD_OPTION',
|
||||
severity: 'warning',
|
||||
message: `Option "${optionId}" in "${v.attrId}" is unconditionally forbidden`,
|
||||
path: v.path,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── REDUNDANT_RULE ─────────────────────────────────────────────────────────
|
||||
// Una regla es redundante si alguna de sus dependencias tiene dominio vacío.
|
||||
|
||||
private checkRedundantRules(graph: GacGraph, issues: DesignIssue[]): void {
|
||||
for (const c of graph.constraints.values()) {
|
||||
if (!c.condition) continue
|
||||
|
||||
const hasDead = c.dependsOn.some(dep => {
|
||||
const v = graph.variables.get(dep)
|
||||
return v && v.fullDomain.size === 0
|
||||
})
|
||||
|
||||
if (hasDead) {
|
||||
issues.push({
|
||||
code: 'REDUNDANT_RULE',
|
||||
severity: 'warning',
|
||||
message: `Rule "${c.id}" can never activate — depends on an empty domain`,
|
||||
path: c.scope,
|
||||
ruleId: c.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TIPOS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type {EntityPathKey} from "@/vice/libs/types.ts";
|
||||
|
||||
export type DesignIssueCode =
|
||||
| 'MISSING_REFERENCE'
|
||||
| 'INVALID_DEFAULT'
|
||||
| 'EMPTY_DOMAIN'
|
||||
| 'DUPLICATE_ID'
|
||||
| 'DEAD_OPTION'
|
||||
| 'REDUNDANT_RULE'
|
||||
|
||||
export interface DesignIssue {
|
||||
code: DesignIssueCode
|
||||
severity: 'error' | 'warning'
|
||||
message: string
|
||||
path: EntityPathKey // dónde ocurre el problema
|
||||
ruleId?: string
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean // false si hay algún error
|
||||
issues: DesignIssue[]
|
||||
}
|
||||
|
||||
Loading…
Reference in new issue