diff --git a/index.html b/index.html new file mode 100644 index 0000000..4e2e969 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + VICE — Catalog Editor + + +
+ + + + + \ No newline at end of file diff --git a/package.json b/package.json index 85898fe..da7c5c1 100644 --- a/package.json +++ b/package.json @@ -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 diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..516702f --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/src/editor/App.svelte b/src/editor/App.svelte new file mode 100644 index 0000000..cf19eb1 --- /dev/null +++ b/src/editor/App.svelte @@ -0,0 +1,25 @@ + + +
+ +
+ diff --git a/src/editor/app.css b/src/editor/app.css new file mode 100644 index 0000000..7ebf2a8 --- /dev/null +++ b/src/editor/app.css @@ -0,0 +1,7 @@ +@import "tailwindcss"; + + +/* Smooth scroll para navegación */ +html { + scroll-behavior: smooth; +} \ No newline at end of file diff --git a/src/editor/components/layout/EditorLayout.svelte b/src/editor/components/layout/EditorLayout.svelte new file mode 100644 index 0000000..729180a --- /dev/null +++ b/src/editor/components/layout/EditorLayout.svelte @@ -0,0 +1,56 @@ + + +
+ + + + +
+ + + + + +
+ +
+ +
+
\ No newline at end of file diff --git a/src/editor/components/layout/Sidebar.svelte b/src/editor/components/layout/Sidebar.svelte new file mode 100644 index 0000000..febcead --- /dev/null +++ b/src/editor/components/layout/Sidebar.svelte @@ -0,0 +1,67 @@ + + +
+ + +
+ VICE + | + + {editor.catalog.name.es || 'Nuevo catálogo'} + + {#if editor.isDirty} + ● Sin guardar + {/if} +
+ + +
+ + + {#if !editor.validation.valid} + + + {editor.validation.errors.length} error{editor.validation.errors.length !== 1 ? 'es' : ''} + + {:else} + + + Válido + + {/if} + + + + + + + + + + +
+
\ No newline at end of file diff --git a/src/editor/components/layout/TopBar.svelte b/src/editor/components/layout/TopBar.svelte new file mode 100644 index 0000000..febcead --- /dev/null +++ b/src/editor/components/layout/TopBar.svelte @@ -0,0 +1,67 @@ + + +
+ + +
+ VICE + | + + {editor.catalog.name.es || 'Nuevo catálogo'} + + {#if editor.isDirty} + ● Sin guardar + {/if} +
+ + +
+ + + {#if !editor.validation.valid} + + + {editor.validation.errors.length} error{editor.validation.errors.length !== 1 ? 'es' : ''} + + {:else} + + + Válido + + {/if} + + + + + + + + + + +
+
\ No newline at end of file diff --git a/src/editor/engines/id-generator.ts b/src/editor/engines/id-generator.ts new file mode 100644 index 0000000..a146bd2 --- /dev/null +++ b/src/editor/engines/id-generator.ts @@ -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, ''); +} \ No newline at end of file diff --git a/src/editor/engines/validator.ts b/src/editor/engines/validator.ts new file mode 100644 index 0000000..423f639 --- /dev/null +++ b/src/editor/engines/validator.ts @@ -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(); + + 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(); + + 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, + 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, + 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 { + const ids = new Set(); + 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; +} + diff --git a/src/editor/main.ts b/src/editor/main.ts new file mode 100644 index 0000000..266660e --- /dev/null +++ b/src/editor/main.ts @@ -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; + diff --git a/src/editor/stores/editor.svelte.ts b/src/editor/stores/editor.svelte.ts new file mode 100644 index 0000000..8a21c3d --- /dev/null +++ b/src/editor/stores/editor.svelte.ts @@ -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('catalog'); + + /** ID del objeto activo en el editor de objetos */ + activeObjectId = $state(null); + + /** ID de la sección activa en el editor de secciones */ + activeSectionId = $state(null); + + /** ID del atributo activo */ + activeAttributeId = $state(null); + + /** ID de la opción activa en la librería */ + activeOptionId = $state(null); + + /** ID de la regla activa */ + activeRuleId = $state(null); + + // ------------------------------------------------------------------------- + // Catálogo en edición + // ------------------------------------------------------------------------- + + catalog = $state(this.#emptyCatalog()); + + // ------------------------------------------------------------------------- + // Estado del editor + // ------------------------------------------------------------------------- + + /** ¿Hay cambios sin guardar? */ + isDirty = $state(false); + + /** Resultado de la última validación */ + validation = $state({ valid: true, errors: [], warnings: [] }); + + /** ¿Está el panel de preview abierto? */ + previewOpen = $state(false); + + /** ¿Está el panel de export abierto? */ + exportOpen = $state(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>): 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): 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): 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): 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): 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): 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 = {}; + 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 = {}; + for (const obj of this.catalog.objects) { + const sections: Record = {}; + 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 = {}; + 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 {}; + } + } +} + diff --git a/src/editor/types/editor.ts b/src/editor/types/editor.ts new file mode 100644 index 0000000..86dd244 --- /dev/null +++ b/src/editor/types/editor.ts @@ -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[]; +} + diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 04bf26e..0000000 --- a/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log('Happy developing ✨') diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..96b3455 --- /dev/null +++ b/svelte.config.js @@ -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(), +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..31c18cf --- /dev/null +++ b/tsconfig.app.json @@ -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"] +} diff --git a/tsconfig.json b/tsconfig.json index 1b00c10..5176913 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "@/constants/*" : ["constants/*"], "@/adapters/*" : ["adapters/*"], "@/svelte/*" : ["adapters/svelte/*"], + "@/editor/*" : ["editor/*"], "@/*" : ["*"] }, diff --git a/vitest.config.ts b/vite.config.ts similarity index 87% rename from vitest.config.ts rename to vite.config.ts index f38c275..ce70c2a 100644 --- a/vitest.config.ts +++ b/vite.config.ts @@ -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',