First Commit

master
dev 7 months ago
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>

@ -4,7 +4,8 @@
"description": "",
"main": "dist/index.js",
"scripts": {
"build": "tsc"
"build": "tsc",
"dev": "vite dev"
},
"keywords": [
"configuration",
@ -13,18 +14,27 @@
],
"author": "ACTIVE THING",
"license": "EULA",
"dependencies": {
"svelte": "^5.53.0"
"svelte": "^5.53.0",
"@tailwindcss/vite": "^4.1.18"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/postcss": "^4.1.18",
"@tsconfig/svelte": "^5.0.6",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/node": "^25.2.3",
"jsdom": "^28.1.0",
"ts-node": "^10.9.2",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-singlefile": "^2.3.0",
"vitest": "^4.0.18"
},
"private": true

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

@ -11,6 +11,7 @@
"@/constants/*" : ["constants/*"],
"@/adapters/*" : ["adapters/*"],
"@/svelte/*" : ["adapters/svelte/*"],
"@/editor/*" : ["editor/*"],
"@/*" : ["*"]
},

@ -1,9 +1,11 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [svelte()],
base: './',
plugins: [svelte(),tailwindcss()],
test: {
globals: true,
environment: 'node',
Loading…
Cancel
Save

Powered by TurnKey Linux.