parent
850df4f151
commit
ef29d9e5dd
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>VICE — Catalog Editor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/editor/main.ts"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* VICE Catalog Editor — Root Component
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Provee el EditorStore via contexto Svelte.
|
||||||
|
* Todos los componentes hijos lo consumen con getContext('editor').
|
||||||
|
*/
|
||||||
|
import './app.css';
|
||||||
|
|
||||||
|
import { setContext } from 'svelte';
|
||||||
|
import { EditorStore } from '@/editor/stores/editor.svelte';
|
||||||
|
import EditorLayout from '@/editor/components/layout/EditorLayout.svelte';
|
||||||
|
|
||||||
|
const editor = new EditorStore();
|
||||||
|
setContext('editor', editor);
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<EditorLayout />
|
||||||
|
</main>
|
||||||
|
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
|
||||||
|
/* Smooth scroll para navegación */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* EDITOR LAYOUT
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Layout principal del editor:
|
||||||
|
* TopBar | Sidebar | MainPanel
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import type { EditorStore } from '@/editor/stores/editor.svelte';
|
||||||
|
import TopBar from './TopBar.svelte';
|
||||||
|
import Sidebar from './Sidebar.svelte';
|
||||||
|
/*
|
||||||
|
import CatalogPanel from '../catalog/CatalogPanel.svelte';
|
||||||
|
import OptionsPanel from '../options/OptionsPanel.svelte';
|
||||||
|
import ObjectsPanel from '../objects/ObjectsPanel.svelte';
|
||||||
|
import RulesPanel from '../rules/RulesPanel.svelte';
|
||||||
|
import PreviewPanel from '../preview/PreviewPanel.svelte';
|
||||||
|
import ExportPanel from '../export/ExportPanel.svelte';
|
||||||
|
*/
|
||||||
|
const editor = getContext<EditorStore>('editor');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-screen bg-gray-950 text-gray-100 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Top Bar -->
|
||||||
|
<TopBar />
|
||||||
|
|
||||||
|
<div class="flex flex-1 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<!-- Main Panel -->
|
||||||
|
<main class="flex-1 overflow-auto">
|
||||||
|
<!--
|
||||||
|
{#if editor.activePanel === 'catalog'}
|
||||||
|
<CatalogPanel />
|
||||||
|
{:else if editor.activePanel === 'options'}
|
||||||
|
<OptionsPanel />
|
||||||
|
{:else if editor.activePanel === 'objects'}
|
||||||
|
<ObjectsPanel />
|
||||||
|
{:else if editor.activePanel === 'rules'}
|
||||||
|
<RulesPanel />
|
||||||
|
{:else if editor.activePanel === 'preview'}
|
||||||
|
<PreviewPanel />
|
||||||
|
{:else if editor.activePanel === 'export'}
|
||||||
|
<ExportPanel />
|
||||||
|
{/if}
|
||||||
|
-->
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import type { EditorStore } from '@/editor/stores/editor.svelte';
|
||||||
|
|
||||||
|
const editor = getContext<EditorStore>('editor');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-800 shrink-0">
|
||||||
|
|
||||||
|
<!-- Logo + nombre catálogo -->
|
||||||
|
<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">
|
||||||
|
{editor.catalog.name.es || 'Nuevo catálogo'}
|
||||||
|
</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 rápida -->
|
||||||
|
{#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?')) 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>
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import type { EditorStore } from '@/editor/stores/editor.svelte';
|
||||||
|
|
||||||
|
const editor = getContext<EditorStore>('editor');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-800 shrink-0">
|
||||||
|
|
||||||
|
<!-- Logo + nombre catálogo -->
|
||||||
|
<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">
|
||||||
|
{editor.catalog.name.es || 'Nuevo catálogo'}
|
||||||
|
</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 rápida -->
|
||||||
|
{#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?')) 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>
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* ID GENERATOR
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Genera IDs únicos con el prefijo correcto para cada entidad.
|
||||||
|
* Formato: prefix:timestamp_random
|
||||||
|
* Ejemplo: op:1709123456_a3f2
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function generateId(prefix: 'op' | 'at' | 'sc' | 'ob' | 'rl' | 'ct' | 'vw'): string {
|
||||||
|
const timestamp = Date.now().toString(36);
|
||||||
|
const random = Math.random().toString(36).slice(2, 6);
|
||||||
|
return `${prefix}:${timestamp}_${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera un code limpio a partir de un nombre.
|
||||||
|
* 'Suelo Parquet' → 'suelo_parquet'
|
||||||
|
*/
|
||||||
|
export function generateCode(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '') // eliminar acentos
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_|_$/g, '');
|
||||||
|
}
|
||||||
@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* CATALOG VALIDATOR
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Valida un EditorCatalog y devuelve errores y warnings con path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EditorCatalog,
|
||||||
|
EditorOption,
|
||||||
|
EditorObject,
|
||||||
|
EditorSection,
|
||||||
|
EditorAttribute,
|
||||||
|
EditorRule,
|
||||||
|
ValidationResult,
|
||||||
|
ValidationError,
|
||||||
|
} from '../types/editor';
|
||||||
|
|
||||||
|
export function validateCatalog(catalog: EditorCatalog): ValidationResult {
|
||||||
|
const errors : ValidationError[] = [];
|
||||||
|
const warnings: ValidationError[] = [];
|
||||||
|
|
||||||
|
const push = (severity: 'error' | 'warning', path: string, message: string) => {
|
||||||
|
(severity === 'error' ? errors : warnings).push({ path, message, severity });
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Catálogo
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
if (!catalog.code) push('error', 'catalog.code', 'El catálogo necesita un código');
|
||||||
|
if (!catalog.name.es) push('error', 'catalog.name.es', 'El catálogo necesita nombre en español');
|
||||||
|
if (!catalog.basePath) push('warning', 'catalog.basePath', 'Sin basePath — las imágenes no se resolverán');
|
||||||
|
if (catalog.objects.length === 0) push('warning', 'catalog.objects', 'El catálogo no tiene objetos');
|
||||||
|
if (catalog.options.length === 0) push('warning', 'catalog.options', 'El catálogo no tiene opciones');
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Opciones
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const optionIds = new Set(catalog.options.map(o => o.id));
|
||||||
|
const optionCodes = new Set<string>();
|
||||||
|
|
||||||
|
for (const opt of catalog.options) {
|
||||||
|
const p = `options.${opt.id}`;
|
||||||
|
if (!opt.code) push('error', `${p}.code`, `Opción "${opt.id}" sin código`);
|
||||||
|
if (!opt.name.es) push('error', `${p}.name.es`, `Opción "${opt.code || opt.id}" sin nombre`);
|
||||||
|
|
||||||
|
if (opt.code && optionCodes.has(opt.code)) {
|
||||||
|
push('error', `${p}.code`, `Código de opción duplicado: "${opt.code}"`);
|
||||||
|
}
|
||||||
|
if (opt.code) optionCodes.add(opt.code);
|
||||||
|
|
||||||
|
if (opt.pricing.type === 'fixed' && opt.pricing.baseAmount < 0) {
|
||||||
|
push('warning', `${p}.pricing`, `Precio negativo en opción "${opt.code}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Objetos
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const objectCodes = new Set<string>();
|
||||||
|
|
||||||
|
for (const obj of catalog.objects) {
|
||||||
|
const p = `objects.${obj.id}`;
|
||||||
|
if (!obj.code) push('error', `${p}.code`, `Objeto "${obj.id}" sin código`);
|
||||||
|
if (!obj.name.es) push('error', `${p}.name.es`, `Objeto "${obj.code || obj.id}" sin nombre`);
|
||||||
|
|
||||||
|
if (obj.code && objectCodes.has(obj.code)) {
|
||||||
|
push('error', `${p}.code`, `Código de objeto duplicado: "${obj.code}"`);
|
||||||
|
}
|
||||||
|
if (obj.code) objectCodes.add(obj.code);
|
||||||
|
|
||||||
|
if (obj.sections.length === 0) {
|
||||||
|
push('warning', `${p}.sections`, `Objeto "${obj.code}" sin secciones`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secciones
|
||||||
|
for (const sec of obj.sections) {
|
||||||
|
validateSection(sec, `${p}.sections.${sec.id}`, optionIds, push);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atributos globales
|
||||||
|
for (const attr of obj.attributes) {
|
||||||
|
validateAttribute(attr, `${p}.attributes.${attr.id}`, optionIds, push);
|
||||||
|
}
|
||||||
|
|
||||||
|
// sectionOrder coherente
|
||||||
|
for (const sid of obj.sectionOrder) {
|
||||||
|
if (!obj.sections.find(s => s.id === sid)) {
|
||||||
|
push('error', `${p}.sectionOrder`, `sectionOrder referencia sección inexistente: "${sid}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Reglas
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const allAttrIds = collectAllAttrIds(catalog);
|
||||||
|
|
||||||
|
for (const rule of catalog.rules) {
|
||||||
|
const p = `rules.${rule.id}`;
|
||||||
|
if (!rule.name.es) push('warning', `${p}.name`, `Regla "${rule.id}" sin nombre`);
|
||||||
|
if (!rule.message.es) push('warning', `${p}.message`, `Regla "${rule.id}" sin mensaje`);
|
||||||
|
|
||||||
|
// Validar que los atributos referenciados existen
|
||||||
|
const condAttrId = extractConditionAttrId(rule.condition);
|
||||||
|
if (condAttrId && !allAttrIds.has(condAttrId)) {
|
||||||
|
push('error', `${p}.condition`, `Condición referencia atributo inexistente: "${condAttrId}"`);
|
||||||
|
}
|
||||||
|
if (!allAttrIds.has(rule.action.targetAttrId)) {
|
||||||
|
push('error', `${p}.action.targetAttr`, `Acción referencia atributo inexistente: "${rule.action.targetAttrId}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que las opciones referenciadas existen
|
||||||
|
for (const optId of rule.action.values) {
|
||||||
|
if (!optionIds.has(optId)) {
|
||||||
|
push('error', `${p}.action.values`, `Acción referencia opción inexistente: "${optId}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function validateSection(
|
||||||
|
sec : EditorSection,
|
||||||
|
path: string,
|
||||||
|
optionIds: Set<string>,
|
||||||
|
push: (severity: 'error' | 'warning', path: string, message: string) => void,
|
||||||
|
): void {
|
||||||
|
if (!sec.code) push('error', `${path}.code`, `Sección "${sec.id}" sin código`);
|
||||||
|
if (!sec.name.es) push('error', `${path}.name.es`, `Sección "${sec.code || sec.id}" sin nombre`);
|
||||||
|
|
||||||
|
if (sec.kind === 'visual') {
|
||||||
|
if (sec.views.length === 0) {
|
||||||
|
push('warning', `${path}.views`, `Sección visual "${sec.code}" sin vistas`);
|
||||||
|
}
|
||||||
|
if (!sec.defaultView) {
|
||||||
|
push('warning', `${path}.defaultView`, `Sección "${sec.code}" sin defaultView`);
|
||||||
|
}
|
||||||
|
for (const view of sec.views) {
|
||||||
|
if (!view.template) {
|
||||||
|
push('warning', `${path}.views.${view.id}.template`, `Vista "${view.code}" sin template`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sec.attributes.length === 0) {
|
||||||
|
push('warning', `${path}.attributes`, `Sección "${sec.code}" sin atributos`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attr of sec.attributes) {
|
||||||
|
validateAttribute(attr, `${path}.attributes.${attr.id}`, optionIds, push);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAttribute(
|
||||||
|
attr : EditorAttribute,
|
||||||
|
path : string,
|
||||||
|
optionIds: Set<string>,
|
||||||
|
push : (severity: 'error' | 'warning', path: string, message: string) => void,
|
||||||
|
): void {
|
||||||
|
if (!attr.code) push('error', `${path}.code`, `Atributo "${attr.id}" sin código`);
|
||||||
|
if (!attr.name.es) push('error', `${path}.name.es`, `Atributo "${attr.code || attr.id}" sin nombre`);
|
||||||
|
|
||||||
|
if (attr.type === 'dynamic') {
|
||||||
|
if (attr.options.length === 0) {
|
||||||
|
push('warning', `${path}.options`, `Atributo "${attr.code}" sin opciones`);
|
||||||
|
}
|
||||||
|
for (const attrOpt of attr.options) {
|
||||||
|
if (!optionIds.has(attrOpt.optionId)) {
|
||||||
|
push('error', `${path}.options`, `Atributo "${attr.code}" referencia opción inexistente: "${attrOpt.optionId}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attr.defaultValue && !attr.options.some(o => o.optionId === attr.defaultValue)) {
|
||||||
|
push('error', `${path}.defaultValue`, `Atributo "${attr.code}": defaultValue no está en las opciones`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAllAttrIds(catalog: EditorCatalog): Set<string> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const obj of catalog.objects) {
|
||||||
|
for (const attr of obj.attributes) ids.add(attr.id);
|
||||||
|
for (const sec of obj.sections) {
|
||||||
|
for (const attr of sec.attributes) ids.add(attr.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractConditionAttrId(condition: EditorRule['condition']): string | null {
|
||||||
|
if (condition.type === 'attribute_equals' || condition.type === 'attribute_in') {
|
||||||
|
return condition.attrId;
|
||||||
|
}
|
||||||
|
if (condition.type === 'and' || condition.type === 'or') {
|
||||||
|
return extractConditionAttrId(condition.conditions[0]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* VICE Catalog Editor — Entry Point
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mount } from 'svelte';
|
||||||
|
import App from './App.svelte';
|
||||||
|
|
||||||
|
const app = mount(App, {
|
||||||
|
target: document.getElementById('app')!
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
|
||||||
@ -0,0 +1,587 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* EDITOR STORE — Svelte 5 (Runas)
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Estado central del editor de catálogos.
|
||||||
|
* Gestiona la navegación entre secciones del editor, el catálogo en edición,
|
||||||
|
* validación en tiempo real y exportación a JSON.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { generateId } from '../engines/id-generator.ts';
|
||||||
|
import { validateCatalog } from '../engines/validator.ts';
|
||||||
|
import type {
|
||||||
|
EditorCatalog,
|
||||||
|
EditorObject,
|
||||||
|
EditorSection,
|
||||||
|
EditorAttribute,
|
||||||
|
EditorOption,
|
||||||
|
EditorRule,
|
||||||
|
ValidationResult,
|
||||||
|
EditorPanel,
|
||||||
|
} from '../types/editor';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EDITOR STORE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class EditorStore {
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Navegación — panel activo
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Panel activo en el editor */
|
||||||
|
activePanel = $state<EditorPanel>('catalog');
|
||||||
|
|
||||||
|
/** ID del objeto activo en el editor de objetos */
|
||||||
|
activeObjectId = $state<string | null>(null);
|
||||||
|
|
||||||
|
/** ID de la sección activa en el editor de secciones */
|
||||||
|
activeSectionId = $state<string | null>(null);
|
||||||
|
|
||||||
|
/** ID del atributo activo */
|
||||||
|
activeAttributeId = $state<string | null>(null);
|
||||||
|
|
||||||
|
/** ID de la opción activa en la librería */
|
||||||
|
activeOptionId = $state<string | null>(null);
|
||||||
|
|
||||||
|
/** ID de la regla activa */
|
||||||
|
activeRuleId = $state<string | null>(null);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Catálogo en edición
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
catalog = $state<EditorCatalog>(this.#emptyCatalog());
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Estado del editor
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** ¿Hay cambios sin guardar? */
|
||||||
|
isDirty = $state<boolean>(false);
|
||||||
|
|
||||||
|
/** Resultado de la última validación */
|
||||||
|
validation = $state<ValidationResult>({ valid: true, errors: [], warnings: [] });
|
||||||
|
|
||||||
|
/** ¿Está el panel de preview abierto? */
|
||||||
|
previewOpen = $state<boolean>(false);
|
||||||
|
|
||||||
|
/** ¿Está el panel de export abierto? */
|
||||||
|
exportOpen = $state<boolean>(false);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Derived — navegación
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
activeObject = $derived(
|
||||||
|
this.activeObjectId
|
||||||
|
? this.catalog.objects.find(o => o.id === this.activeObjectId) ?? null
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
activeSection = $derived(
|
||||||
|
this.activeObject && this.activeSectionId
|
||||||
|
? this.activeObject.sections.find(s => s.id === this.activeSectionId) ?? null
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
activeAttribute = $derived(
|
||||||
|
this.activeSection && this.activeAttributeId
|
||||||
|
? this.activeSection.attributes.find(a => a.id === this.activeAttributeId) ?? null
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
activeOption = $derived(
|
||||||
|
this.activeOptionId
|
||||||
|
? this.catalog.options.find(o => o.id === this.activeOptionId) ?? null
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
activeRule = $derived(
|
||||||
|
this.activeRuleId
|
||||||
|
? this.catalog.rules.find(r => r.id === this.activeRuleId) ?? null
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Opciones disponibles para usar en atributos — toda la librería */
|
||||||
|
optionLibrary = $derived(this.catalog.options);
|
||||||
|
|
||||||
|
/** ¿El catálogo tiene suficiente información para previsualizarse? */
|
||||||
|
canPreview = $derived(
|
||||||
|
this.catalog.objects.length > 0 &&
|
||||||
|
this.catalog.options.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
/** JSON exportable del catálogo */
|
||||||
|
exportJson = $derived(
|
||||||
|
this.exportOpen ? this.#buildExportJson() : null
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Navegación — API pública
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
setPanel(panel: EditorPanel): void {
|
||||||
|
this.activePanel = panel;
|
||||||
|
this.activeSectionId = null;
|
||||||
|
this.activeAttributeId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveObject(id: string | null): void {
|
||||||
|
this.activeObjectId = id;
|
||||||
|
this.activeSectionId = null;
|
||||||
|
this.activeAttributeId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSection(id: string | null): void {
|
||||||
|
this.activeSectionId = id;
|
||||||
|
this.activeAttributeId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveAttribute(id: string | null): void {
|
||||||
|
this.activeAttributeId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveOption(id: string | null): void {
|
||||||
|
this.activeOptionId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveRule(id: string | null): void {
|
||||||
|
this.activeRuleId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePreview(): void { this.previewOpen = !this.previewOpen; }
|
||||||
|
toggleExport(): void {
|
||||||
|
this.exportOpen = !this.exportOpen;
|
||||||
|
if (this.exportOpen) this.validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Catálogo — metadatos
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
updateCatalogMeta(patch: Partial<Pick<EditorCatalog, 'name' | 'code' | 'description' | 'basePath'>>): void {
|
||||||
|
Object.assign(this.catalog, patch);
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Opciones — CRUD
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
addOption(): EditorOption {
|
||||||
|
const option: EditorOption = {
|
||||||
|
id : generateId('op'),
|
||||||
|
code : '',
|
||||||
|
name : { es: '', en: '' },
|
||||||
|
description: { es: '', en: '' },
|
||||||
|
pricing : { type: 'fixed', baseAmount: 0 },
|
||||||
|
tags : [],
|
||||||
|
};
|
||||||
|
this.catalog.options.push(option);
|
||||||
|
this.activeOptionId = option.id;
|
||||||
|
this.#markDirty();
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOption(id: string, patch: Partial<EditorOption>): void {
|
||||||
|
const idx = this.catalog.options.findIndex(o => o.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
Object.assign(this.catalog.options[idx], patch);
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteOption(id: string): void {
|
||||||
|
this.catalog.options = this.catalog.options.filter(o => o.id !== id);
|
||||||
|
if (this.activeOptionId === id) this.activeOptionId = null;
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicateOption(id: string): void {
|
||||||
|
const src = this.catalog.options.find(o => o.id === id);
|
||||||
|
if (!src) return;
|
||||||
|
const copy: EditorOption = {
|
||||||
|
...structuredClone(src),
|
||||||
|
id : generateId('op'),
|
||||||
|
code: src.code + '_copy',
|
||||||
|
name: { es: src.name.es + ' (copia)', en: src.name.en + ' (copy)' },
|
||||||
|
};
|
||||||
|
this.catalog.options.push(copy);
|
||||||
|
this.activeOptionId = copy.id;
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Objetos — CRUD
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
addObject(): EditorObject {
|
||||||
|
const object: EditorObject = {
|
||||||
|
id : generateId('ob'),
|
||||||
|
code : '',
|
||||||
|
name : { es: '', en: '' },
|
||||||
|
description : { es: '', en: '' },
|
||||||
|
category : '',
|
||||||
|
basePath : '',
|
||||||
|
sections : [],
|
||||||
|
sectionOrder: [],
|
||||||
|
attributes : [],
|
||||||
|
};
|
||||||
|
this.catalog.objects.push(object);
|
||||||
|
this.activeObjectId = object.id;
|
||||||
|
this.activeSectionId = null;
|
||||||
|
this.#markDirty();
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateObject(id: string, patch: Partial<EditorObject>): void {
|
||||||
|
const obj = this.catalog.objects.find(o => o.id === id);
|
||||||
|
if (!obj) return;
|
||||||
|
Object.assign(obj, patch);
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteObject(id: string): void {
|
||||||
|
this.catalog.objects = this.catalog.objects.filter(o => o.id !== id);
|
||||||
|
if (this.activeObjectId === id) {
|
||||||
|
this.activeObjectId = null;
|
||||||
|
this.activeSectionId = null;
|
||||||
|
}
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
reorderSections(objectId: string, order: string[]): void {
|
||||||
|
const obj = this.catalog.objects.find(o => o.id === objectId);
|
||||||
|
if (!obj) return;
|
||||||
|
obj.sectionOrder = order;
|
||||||
|
obj.sections = order
|
||||||
|
.map(id => obj.sections.find(s => s.id === id))
|
||||||
|
.filter((s): s is EditorSection => s !== undefined);
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Secciones — CRUD
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
addSection(objectId: string): EditorSection {
|
||||||
|
const obj = this.catalog.objects.find(o => o.id === objectId);
|
||||||
|
if (!obj) throw new Error(`Object ${objectId} not found`);
|
||||||
|
|
||||||
|
const section: EditorSection = {
|
||||||
|
id : generateId('sc'),
|
||||||
|
code : '',
|
||||||
|
kind : 'visual',
|
||||||
|
name : { es: '', en: '' },
|
||||||
|
description: { es: '', en: '' },
|
||||||
|
attributes : [],
|
||||||
|
views : [],
|
||||||
|
defaultView: null,
|
||||||
|
availability: { mode: 'required' },
|
||||||
|
};
|
||||||
|
|
||||||
|
obj.sections.push(section);
|
||||||
|
obj.sectionOrder.push(section.id);
|
||||||
|
this.activeSectionId = section.id;
|
||||||
|
this.#markDirty();
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSection(objectId: string, sectionId: string, patch: Partial<EditorSection>): void {
|
||||||
|
const section = this.#findSection(objectId, sectionId);
|
||||||
|
if (!section) return;
|
||||||
|
Object.assign(section, patch);
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSection(objectId: string, sectionId: string): void {
|
||||||
|
const obj = this.catalog.objects.find(o => o.id === objectId);
|
||||||
|
if (!obj) return;
|
||||||
|
obj.sections = obj.sections.filter(s => s.id !== sectionId);
|
||||||
|
obj.sectionOrder = obj.sectionOrder.filter(id => id !== sectionId);
|
||||||
|
if (this.activeSectionId === sectionId) this.activeSectionId = null;
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Atributos — CRUD
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
addAttribute(objectId: string, sectionId: string): EditorAttribute {
|
||||||
|
const section = this.#findSection(objectId, sectionId);
|
||||||
|
if (!section) throw new Error(`Section ${sectionId} not found`);
|
||||||
|
|
||||||
|
const attr: EditorAttribute = {
|
||||||
|
id : generateId('at'),
|
||||||
|
code : '',
|
||||||
|
name : { es: '', en: '' },
|
||||||
|
description : { es: '', en: '' },
|
||||||
|
type : 'dynamic',
|
||||||
|
dataType : 'reference',
|
||||||
|
defaultValue: null,
|
||||||
|
options : [],
|
||||||
|
display : {
|
||||||
|
uiVisible : true,
|
||||||
|
affectsVisual: true,
|
||||||
|
affectsPrice : true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
section.attributes.push(attr);
|
||||||
|
this.activeAttributeId = attr.id;
|
||||||
|
this.#markDirty();
|
||||||
|
return attr;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAttribute(objectId: string, sectionId: string, attrId: string, patch: Partial<EditorAttribute>): void {
|
||||||
|
const section = this.#findSection(objectId, sectionId);
|
||||||
|
const attr = section?.attributes.find(a => a.id === attrId);
|
||||||
|
if (!attr) return;
|
||||||
|
Object.assign(attr, patch);
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAttribute(objectId: string, sectionId: string, attrId: string): void {
|
||||||
|
const section = this.#findSection(objectId, sectionId);
|
||||||
|
if (!section) return;
|
||||||
|
section.attributes = section.attributes.filter(a => a.id !== attrId);
|
||||||
|
if (this.activeAttributeId === attrId) this.activeAttributeId = null;
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Añade una opción de la librería a un atributo */
|
||||||
|
addOptionToAttribute(objectId: string, sectionId: string, attrId: string, optionId: string): void {
|
||||||
|
const section = this.#findSection(objectId, sectionId);
|
||||||
|
const attr = section?.attributes.find(a => a.id === attrId);
|
||||||
|
if (!attr) return;
|
||||||
|
if (attr.options.some(o => o.optionId === optionId)) return; // ya existe
|
||||||
|
attr.options.push({
|
||||||
|
optionId,
|
||||||
|
priority : attr.options.length + 1,
|
||||||
|
quantity : null,
|
||||||
|
dynamicExpression: null,
|
||||||
|
});
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeOptionFromAttribute(objectId: string, sectionId: string, attrId: string, optionId: string): void {
|
||||||
|
const section = this.#findSection(objectId, sectionId);
|
||||||
|
const attr = section?.attributes.find(a => a.id === attrId);
|
||||||
|
if (!attr) return;
|
||||||
|
attr.options = attr.options.filter(o => o.optionId !== optionId);
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Reglas — CRUD
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
addRule(): EditorRule {
|
||||||
|
const rule: EditorRule = {
|
||||||
|
id : generateId('rl'),
|
||||||
|
name : { es: '', en: '' },
|
||||||
|
condition: { type: 'attribute_equals', attrId: '', value: '' },
|
||||||
|
action : { type: 'forbid', targetAttrId: '', values: [] },
|
||||||
|
severity : 'error',
|
||||||
|
priority : this.catalog.rules.length + 1,
|
||||||
|
message : { es: '', en: '' },
|
||||||
|
};
|
||||||
|
this.catalog.rules.push(rule);
|
||||||
|
this.activeRuleId = rule.id;
|
||||||
|
this.#markDirty();
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRule(id: string, patch: Partial<EditorRule>): void {
|
||||||
|
const rule = this.catalog.rules.find(r => r.id === id);
|
||||||
|
if (!rule) return;
|
||||||
|
Object.assign(rule, patch);
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRule(id: string): void {
|
||||||
|
this.catalog.rules = this.catalog.rules.filter(r => r.id !== id);
|
||||||
|
if (this.activeRuleId === id) this.activeRuleId = null;
|
||||||
|
this.#markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Validación y exportación
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
validate(): ValidationResult {
|
||||||
|
this.validation = validateCatalog(this.catalog);
|
||||||
|
return this.validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportToJson(): string {
|
||||||
|
this.validate();
|
||||||
|
return JSON.stringify(this.#buildExportJson(), null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Importa un catálogo desde JSON */
|
||||||
|
importFromJson(json: string): void {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
this.catalog = parsed as EditorCatalog;
|
||||||
|
this.validate();
|
||||||
|
this.isDirty = false;
|
||||||
|
} catch (e) {
|
||||||
|
this.validation = {
|
||||||
|
valid : false,
|
||||||
|
errors : [{ severity: 'error', path: 'root', message: 'JSON inválido' }],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resetea el editor a un catálogo vacío */
|
||||||
|
reset(): void {
|
||||||
|
this.catalog = this.#emptyCatalog();
|
||||||
|
this.activeObjectId = null;
|
||||||
|
this.activeSectionId = null;
|
||||||
|
this.activeAttributeId = null;
|
||||||
|
this.activeOptionId = null;
|
||||||
|
this.activeRuleId = null;
|
||||||
|
this.activePanel = 'catalog';
|
||||||
|
this.isDirty = false;
|
||||||
|
this.validation = { valid: true, errors: [], warnings: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers privados
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#findSection(objectId: string, sectionId: string): EditorSection | undefined {
|
||||||
|
return this.catalog.objects
|
||||||
|
.find(o => o.id === objectId)
|
||||||
|
?.sections.find(s => s.id === sectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#markDirty(): void {
|
||||||
|
this.isDirty = true;
|
||||||
|
// Validación incremental en background
|
||||||
|
this.validation = validateCatalog(this.catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
#emptyCatalog(): EditorCatalog {
|
||||||
|
return {
|
||||||
|
id : generateId('ct'),
|
||||||
|
code : '',
|
||||||
|
name : { es: '', en: '' },
|
||||||
|
description: { es: '', en: '' },
|
||||||
|
basePath : '/renders/',
|
||||||
|
options : [],
|
||||||
|
objects : [],
|
||||||
|
rules : [],
|
||||||
|
tax : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#buildExportJson(): object {
|
||||||
|
// Transforma el EditorCatalog al formato ConfigurationCatalog
|
||||||
|
const options: Record<string, object> = {};
|
||||||
|
for (const opt of this.catalog.options) {
|
||||||
|
options[opt.id] = {
|
||||||
|
id : opt.id,
|
||||||
|
code : opt.code,
|
||||||
|
name : opt.name,
|
||||||
|
description: opt.description,
|
||||||
|
pricing : opt.pricing.type === 'fixed'
|
||||||
|
? { baseAmount: opt.pricing.baseAmount }
|
||||||
|
: { baseAmount: 0, dynamicExpression: opt.pricing.expression },
|
||||||
|
tags : opt.tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const objects: Record<string, object> = {};
|
||||||
|
for (const obj of this.catalog.objects) {
|
||||||
|
const sections: Record<string, object> = {};
|
||||||
|
for (const sec of obj.sections) {
|
||||||
|
sections[sec.id] = {
|
||||||
|
kind : sec.kind,
|
||||||
|
id : sec.id,
|
||||||
|
code : sec.code,
|
||||||
|
name : sec.name,
|
||||||
|
description : sec.description,
|
||||||
|
attributes : sec.attributes.map(a => ({
|
||||||
|
id : a.id,
|
||||||
|
code : a.code,
|
||||||
|
name : a.name,
|
||||||
|
description : a.description,
|
||||||
|
type : a.type,
|
||||||
|
dataType : a.dataType,
|
||||||
|
defaultValue: a.defaultValue,
|
||||||
|
options : a.options,
|
||||||
|
display : a.display,
|
||||||
|
})),
|
||||||
|
views : Object.fromEntries(
|
||||||
|
sec.views.map(v => [v.id, v])
|
||||||
|
),
|
||||||
|
defaultView : sec.defaultView,
|
||||||
|
availability: sec.availability,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
objects[obj.id] = {
|
||||||
|
id : obj.id,
|
||||||
|
code : obj.code,
|
||||||
|
name : obj.name,
|
||||||
|
description : obj.description,
|
||||||
|
category : obj.category,
|
||||||
|
basePath : obj.basePath || this.catalog.basePath,
|
||||||
|
attributes : obj.attributes,
|
||||||
|
sections,
|
||||||
|
sectionOrder: obj.sectionOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules: Record<string, object> = {};
|
||||||
|
for (const rule of this.catalog.rules) {
|
||||||
|
rules[rule.id] = {
|
||||||
|
id : rule.id,
|
||||||
|
name : rule.name,
|
||||||
|
condition: this.#ruleConditionToJsonLogic(rule.condition),
|
||||||
|
action : {
|
||||||
|
type : rule.action.type,
|
||||||
|
targetAttr: rule.action.targetAttrId,
|
||||||
|
values : rule.action.values,
|
||||||
|
},
|
||||||
|
priority : rule.priority,
|
||||||
|
severity : rule.severity,
|
||||||
|
//affects : [rule.condition.attrId, rule.action.targetAttrId],
|
||||||
|
message : rule.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id : this.catalog.id,
|
||||||
|
code : this.catalog.code,
|
||||||
|
name : this.catalog.name,
|
||||||
|
description: this.catalog.description,
|
||||||
|
basePath : this.catalog.basePath,
|
||||||
|
options,
|
||||||
|
objects,
|
||||||
|
rules,
|
||||||
|
...(this.catalog.tax ? { tax: this.catalog.tax } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#ruleConditionToJsonLogic(condition: EditorRule['condition']): object {
|
||||||
|
switch (condition.type) {
|
||||||
|
case 'attribute_equals':
|
||||||
|
return { '==': [{ var: `attributes.${condition.attrId.replace(':', '_')}` }, condition.value] };
|
||||||
|
case 'attribute_in':
|
||||||
|
return { 'in': [{ var: `attributes.${condition.attrId.replace(':', '_')}` }, condition.values] };
|
||||||
|
case 'and':
|
||||||
|
return { 'and': condition.conditions.map(c => this.#ruleConditionToJsonLogic(c)) };
|
||||||
|
case 'or':
|
||||||
|
return { 'or': condition.conditions.map(c => this.#ruleConditionToJsonLogic(c)) };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* EDITOR TYPES
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Tipos del editor — versión "plana" del catálogo optimizada para edición.
|
||||||
|
* Arrays en lugar de Records para facilitar reordenación y CRUD.
|
||||||
|
* Se transforma a ConfigurationCatalog al exportar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type EditorPanel = 'catalog' | 'options' | 'objects' | 'rules' | 'preview' | 'export';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// I18N
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface I18nField {
|
||||||
|
es: string;
|
||||||
|
en: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PRICING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type EditorPricing =
|
||||||
|
| { type: 'fixed'; baseAmount: number }
|
||||||
|
| { type: 'dynamic'; baseAmount: number; expression: object };
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OPTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface EditorOption {
|
||||||
|
id : string;
|
||||||
|
code : string;
|
||||||
|
name : I18nField;
|
||||||
|
description: I18nField;
|
||||||
|
pricing : EditorPricing;
|
||||||
|
tags : string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ATTRIBUTE OPTION — referencia a opción en un atributo
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface EditorAttributeOption {
|
||||||
|
optionId : string;
|
||||||
|
priority : number;
|
||||||
|
quantity : EditorQuantityConfig | null;
|
||||||
|
dynamicExpression: object | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorQuantityConfig {
|
||||||
|
default: number;
|
||||||
|
min : number;
|
||||||
|
max : number;
|
||||||
|
step : number;
|
||||||
|
unit : I18nField;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ATTRIBUTES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface EditorAttribute {
|
||||||
|
id : string;
|
||||||
|
code : string;
|
||||||
|
name : I18nField;
|
||||||
|
description : I18nField;
|
||||||
|
type : 'dynamic' | 'fixed' | 'computed';
|
||||||
|
dataType : 'reference' | 'number' | 'string' | 'boolean';
|
||||||
|
defaultValue: string | number | null;
|
||||||
|
options : EditorAttributeOption[]; // solo si type === 'dynamic'
|
||||||
|
display : EditorDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorDisplay {
|
||||||
|
uiVisible : boolean;
|
||||||
|
affectsVisual: boolean;
|
||||||
|
affectsPrice : boolean;
|
||||||
|
readonly? : boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VIEWS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type EditorVisualStrategy = 'static_image' | 'composite_layers' | 'api_generated' | 'three_d';
|
||||||
|
|
||||||
|
export interface EditorView {
|
||||||
|
id : string;
|
||||||
|
code : string;
|
||||||
|
name : I18nField;
|
||||||
|
type : 'front' | 'panoramic' | 'detail';
|
||||||
|
strategy: EditorVisualStrategy;
|
||||||
|
template: string; // para static_image
|
||||||
|
basePath: string;
|
||||||
|
fallback: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SECTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface EditorSection {
|
||||||
|
id : string;
|
||||||
|
code : string;
|
||||||
|
kind : 'visual' | 'config';
|
||||||
|
name : I18nField;
|
||||||
|
description : I18nField;
|
||||||
|
attributes : EditorAttribute[];
|
||||||
|
views : EditorView[];
|
||||||
|
defaultView : string | null;
|
||||||
|
availability: { mode: 'required' | 'optional' | 'conditional' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OBJECTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface EditorObject {
|
||||||
|
id : string;
|
||||||
|
code : string;
|
||||||
|
name : I18nField;
|
||||||
|
description : I18nField;
|
||||||
|
category : string;
|
||||||
|
basePath : string;
|
||||||
|
attributes : EditorAttribute[]; // atributos globales
|
||||||
|
sections : EditorSection[];
|
||||||
|
sectionOrder: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RULES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type EditorRuleActionType = 'forbid' | 'allow' | 'require' | 'suggest';
|
||||||
|
export type EditorSeverity = 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condición del editor — árbol de condiciones simples.
|
||||||
|
* Se convierte a JsonLogic al exportar.
|
||||||
|
*/
|
||||||
|
export type EditorCondition =
|
||||||
|
| { type: 'attribute_equals'; attrId: string; value: string }
|
||||||
|
| { type: 'attribute_in'; attrId: string; values: string[] }
|
||||||
|
| { type: 'and'; conditions: EditorCondition[] }
|
||||||
|
| { type: 'or'; conditions: EditorCondition[] };
|
||||||
|
|
||||||
|
export interface EditorRuleAction {
|
||||||
|
type : EditorRuleActionType;
|
||||||
|
targetAttrId: string;
|
||||||
|
values : string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorRule {
|
||||||
|
id : string;
|
||||||
|
name : I18nField;
|
||||||
|
condition: EditorCondition;
|
||||||
|
action : EditorRuleAction;
|
||||||
|
severity : EditorSeverity;
|
||||||
|
priority : number;
|
||||||
|
message : I18nField;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TAX
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface EditorTax {
|
||||||
|
rate : number;
|
||||||
|
included: boolean;
|
||||||
|
label : I18nField;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CATALOG
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface EditorCatalog {
|
||||||
|
id : string;
|
||||||
|
code : string;
|
||||||
|
name : I18nField;
|
||||||
|
description: I18nField;
|
||||||
|
basePath : string;
|
||||||
|
options : EditorOption[];
|
||||||
|
objects : EditorObject[];
|
||||||
|
rules : EditorRule[];
|
||||||
|
tax : EditorTax | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
path : string; // ej: 'objects.ob_1.sections.sc_2.attributes.at_3'
|
||||||
|
message: string;
|
||||||
|
severity: 'error' | 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid : boolean;
|
||||||
|
errors : ValidationError[];
|
||||||
|
warnings: ValidationError[];
|
||||||
|
}
|
||||||
|
|
||||||
@ -1 +0,0 @@
|
|||||||
console.log('Happy developing ✨')
|
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||||
|
export default {
|
||||||
|
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["svelte", "vite/client"],
|
||||||
|
"noEmit": true,
|
||||||
|
/**
|
||||||
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
|
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||||
|
* Note that setting allowJs false does not prevent the use
|
||||||
|
* of JS in `.svelte` files.
|
||||||
|
*/
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"moduleDetection": "force"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
@ -1,9 +1,11 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
base: './',
|
||||||
|
plugins: [svelte(),tailwindcss()],
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
Loading…
Reference in new issue