Extras multi-seleccion, imagen opcional en opciones y correccion de nombres

- Grupo "Extras" con seleccion multiple (toggle) por vista: getSelectedIds,
  toggleOption; totales/incidencias/resumen iteran la seleccion.
- Extras en cocina (Tendedero, consultar) y aseo (Eliminar ducha, sin coste
  con imagen en popover). Lista nombre/precio; se oculta si esta vacio.
- Opciones sin imagen de vista (noRender) con icono i + popover (griferia,
  muebles/encimera personalizados); resolucion por clave o value.
- Cocina personalizada: imagenes mbpe_enpe movidas a hi/low (cerrada->inde),
  regla enpe allow:['mbpe'].
- Correccion de erratas en nombres de imagen b2 (ligth/creamt/greyt...) en
  hi, low y DataImagesB64; fix vista b2 (id) y bloqueo de error de imagen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
master
dev 3 months ago
parent 8ba6c5cd04
commit 0cb4345a14

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 323 KiB

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 332 KiB

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 323 KiB

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 297 KiB

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 B

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 639 B

After

Width:  |  Height:  |  Size: 639 B

Before

Width:  |  Height:  |  Size: 641 B

After

Width:  |  Height:  |  Size: 641 B

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 669 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Before

Width:  |  Height:  |  Size: 626 B

After

Width:  |  Height:  |  Size: 626 B

Before

Width:  |  Height:  |  Size: 626 B

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@ -13,13 +13,15 @@
groupId,
viewId,
isDisabled = false,
isSelected = false
isSelected = false,
multiple = false
} = $props<{
optionRef: OptionReference;
groupId: string;
viewId: string;
isDisabled?: boolean;
isSelected?: boolean;
multiple?: boolean;
}>();
const textWithCost = dataConfig.texts.with_cost;
@ -29,25 +31,47 @@
const consultation = $derived(appState.requiresConsultation(viewId, groupId, optionRef.id));
const imgOptSrc = $derived(imgPath + optionValue.thumb);
// Imagen a mostrar en el popover de información (la especificada o, si no,
// la imagen de producto del valor). Vacío si no hay ninguna.
const infoImgUrl = $derived(
optionRef.infoImage
? dataConfig.sources.images + optionRef.infoImage
: (optionValue?.image ? dataConfig.sources.imgOptionsHi + optionValue.image : '')
);
function handleClick() {
if (isDisabled || isSelected) {
if (isDisabled) {
return;
}
// La opción no se refleja en la vista: mostrar su foto/texto en un popover
if (optionRef.noRender) {
if (optionValue?.image) {
popupManager.showImage(imgPath + optionValue.image, optionValue.name ?? '');
} else if (optionValue?.text || optionValue?.name) {
popupManager.showMessage(optionValue.text || optionValue.name);
// Selección múltiple (extras): marcar/desmarcar sin bloquear al estar activo.
if (multiple) {
if (!isSelected && consultation.message) {
popupManager.showMessage(consultation.message);
}
} else if (consultation.message) {
// Mostrar mensaje de consulta si existe
appState.toggleOption(viewId, groupId, optionRef.id);
return;
}
// Selección única (radio): no reaccionar si ya está seleccionada.
if (isSelected) {
return;
}
// Mostrar mensaje de consulta si existe
if (consultation.message) {
popupManager.showMessage(consultation.message);
}
appState.setOption(viewId, groupId, optionRef.id);
}
// Icono de información: abre el popover con la imagen especificada.
function handleInfo(e: MouseEvent) {
e.stopPropagation();
if (infoImgUrl) {
popupManager.showImage(infoImgUrl, optionValue?.name ?? '');
}
}
// Verificar si tiene precio
const hasPrice = $derived(
optionRef.amount !== undefined &&
@ -56,15 +80,16 @@
);
</script>
<div class="relative">
<button
class="relative border-2 rounded-lg p-3 text-left transition-all hover:shadow-md flex flex-col items-center"
class="w-full relative border-2 rounded-lg p-3 text-left transition-all hover:shadow-md flex flex-col items-center"
class:border-green-600={isSelected}
class:ring-offset-1={isSelected}
class:ring-green-600={isSelected}
class:border-gray-200={!isSelected}
class:cursor-not-allowed={isDisabled}
class:cursor-pointer={!isDisabled && !isSelected}
disabled={isDisabled || isSelected}
class:cursor-pointer={!isDisabled && (multiple || !isSelected)}
disabled={isDisabled || (!multiple && isSelected)}
onclick={handleClick}
aria-pressed={isSelected}
aria-disabled={isDisabled}
@ -72,9 +97,6 @@
<!-- Warning indicator -->
{#if consultation.message}
<BadgetIcon type="warning" size="sm" position="top-left" />
{:else if optionRef.noRender}
<!-- Opción sin representación en la vista -->
<BadgetIcon type="info" size="sm" position="top-left" />
{/if}
<!-- Selected indicator -->
@ -102,10 +124,10 @@
{optionValue?.name || optionRef.id}
</span>
<!-- Marca de opción sin representación en la vista -->
<!-- Aviso: opción sin representación en la vista -->
{#if optionRef.noRender}
<span class="text-[10px] italic text-gray-400 text-center leading-tight">
Sin vista previa · pulse para ver
Sin imagen virtual
</span>
{/if}
@ -118,3 +140,20 @@
</div>
{/if}
</button>
<!-- Icono de información: abre el popover con la imagen de la opción.
Es un botón hermano (no anidado) para no invalidar el HTML. -->
{#if optionRef.noRender && infoImgUrl}
<button
type="button"
class="absolute -top-2 -left-2 z-20 w-5 h-5 rounded-full bg-blue-500 text-white flex items-center justify-center shadow-md ring-2 ring-white hover:bg-blue-600 cursor-pointer transition-colors"
onclick={handleInfo}
aria-label={`Ver imagen de ${optionValue?.name ?? 'la opción'}`}
title="Ver imagen"
>
<svg class="w-3 h-3 fill-current" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>

@ -21,6 +21,9 @@
let imageUrl = $state('');
let hasError = $state(false);
let transitionKey = $state('');
// Último nombre de imagen procesado (cargado o fallido). Sirve de guard
// sin retroalimentar el efecto con las banderas de carga/error.
let lastTarget = $state('');
// Estado para controlar la visibilidad de hotspots
let hotspotsVisible = $state(false);
@ -37,8 +40,11 @@
$effect(() => {
const targetImg = currentFileName;
// Si ya estamos mostrando esta imagen, no hacemos nada
if (imageUrl === targetImg) return;
// Solo procesamos cada objetivo una vez. Al cambiar de selección
// (aunque sea volviendo a una imagen previa tras un error), se reintenta
// y se limpia el error; un 404 no bloquea las siguientes imágenes.
if (targetImg === lastTarget) return;
lastTarget = targetImg;
isLoading = true;
hasError = false;
@ -49,6 +55,7 @@
imageUrl = targetImg;
transitionKey = targetImg;
isLoading = false;
hasError = false;
};
img.onerror = () => {

@ -1,8 +1,10 @@
<script lang="ts">
import type {OptionGroup} from "../types/DataConfigTypes";
import {appState} from "../state/AppState.svelte.js";
import {dataConfig} from "../data/DataConfig";
import {formatPrice} from "../utils/formats";
import {popupManager} from "../state/PopsState.svelte";
import BtOption from "./BtOption.svelte";
import {derived} from "svelte/store";
let {
group,
@ -12,41 +14,124 @@
viewId?: string;
}>();
const selectedValue = $derived(appState.getCurrentOption(viewId, group.id));
const options = $derived(appState.getGroupOptions(viewId, group.id));
// Obtener la información de consulta de la opción seleccionada
const selectedConsultation = $derived.by(() => {
if (!selectedValue) return null;
return appState.requiresConsultation(viewId, group.id, selectedValue);
});
const textWithCost = dataConfig.texts.with_cost;
const selectedIds = $derived(appState.getSelectedIds(viewId, group.id));
const options = $derived(appState.getGroupOptions(viewId, group.id));
// En los extras (multi) las opciones no disponibles (según rules) se ocultan;
// en los grupos normales se mantienen (se muestran deshabilitadas).
const visibleOptions = $derived(
group.multiple ? options.filter((o: any) => o.isValid) : options
);
// URL de la imagen del extra para el popover (opcional).
function infoUrl(o: any): string {
if (o.infoImage) return dataConfig.sources.images + o.infoImage;
if (o.image) return dataConfig.sources.imgOptionsHi + o.image;
return '';
}
// Etiqueta de precio a la derecha del extra.
function priceLabel(o: any): string {
if (o.amount === textWithCost) return 'Consultar';
if (typeof o.amount === 'number') {
return o.amount > 0 ? '+ ' + formatPrice(o.amount) : 'Sin coste';
}
return '';
}
function toggleExtra(o: any) {
// Al marcar (no al desmarcar) mostrar el aviso de consulta si lo tiene.
if (!selectedIds.includes(o.id) && o.warn) {
popupManager.showMessage(o.warn);
}
appState.toggleOption(viewId, group.id, o.id);
}
function openInfo(e: MouseEvent, o: any) {
e.stopPropagation();
const url = infoUrl(o);
if (url) popupManager.showImage(url, o.name ?? '');
}
</script>
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-800 mb-3">{group.name || 'Opciones' }</h4>
<p class="text-sm mb-2">{group.text}</p>
<div class="grid grid-cols-2 gap-4">
{#each options as option (option.id)}
{@const isSelected = selectedValue === option.id}
<BtOption
optionRef={option}
groupId={group.id}
{viewId}
{isSelected}
isDisabled={!option.isValid}
/>
{/each}
<!-- Ocultar el bloque si el grupo no tiene opciones visibles (p. ej. Extras vacío) -->
{#if visibleOptions.length > 0}
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-800 mb-3">{group.name || 'Opciones'}</h4>
{#if group.text}<p class="text-sm mb-2">{group.text}</p>{/if}
{#if group.multiple}
<!-- EXTRAS: lista con nombre a la izquierda y precio/"consultar" a la derecha -->
<ul class="border border-gray-200 rounded-lg divide-y divide-gray-200 overflow-hidden">
{#each visibleOptions as option (option.id)}
{@const isSelected = selectedIds.includes(option.id)}
<li>
<div
class="w-full flex items-center justify-between gap-3 p-3 text-left cursor-pointer hover:bg-gray-50 transition-colors"
class:bg-green-50={isSelected}
role="button"
tabindex="0"
aria-pressed={isSelected}
onclick={() => toggleExtra(option)}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), toggleExtra(option))}
>
<span class="flex items-center gap-3 min-w-0">
<!-- Casilla -->
<span
class="flex-shrink-0 w-5 h-5 rounded border flex items-center justify-center"
class:bg-green-600={isSelected}
class:border-green-600={isSelected}
class:border-gray-300={!isSelected}
>
{#if isSelected}
<svg class="w-3.5 h-3.5 text-white fill-none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{/if}
</span>
<span class="text-sm font-medium text-gray-800 truncate">{option.name || option.id}</span>
<!-- Icono de info (solo si el extra tiene imagen) -->
{#if infoUrl(option)}
<button
type="button"
class="flex-shrink-0 w-5 h-5 rounded-full bg-blue-500 text-white flex items-center justify-center hover:bg-blue-600"
onclick={(e) => openInfo(e, option)}
aria-label={`Ver imagen de ${option.name}`}
title="Ver imagen"
>
<svg class="w-3 h-3 fill-current" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</span>
{#if priceLabel(option)}
<span class="flex-shrink-0 text-sm font-semibold text-gray-700">{priceLabel(option)}</span>
{/if}
</div>
</li>
{/each}
</ul>
{:else}
<!-- Grupos normales: rejilla de tarjetas -->
<div class="grid grid-cols-2 gap-4">
{#each visibleOptions as option (option.id)}
{@const isSelected = selectedIds.includes(option.id)}
<BtOption
optionRef={option}
groupId={group.id}
{viewId}
{isSelected}
multiple={group.multiple}
isDisabled={!option.isValid}
/>
{/each}
</div>
{/if}
</div>
</div>
{/if}

@ -70,36 +70,69 @@
<div class="p-4 space-y-2 text-left min-h-[180px]">
{#if view.options.length > 0}
{#each view.options as group}
{@const currentOptionId = appState.getCurrentOption(view.id, group.id)}
{@const optionValue = currentOptionId ? appState.getOptionValue(currentOptionId) : null}
{@const requiresConsult = currentOptionId ? appState.requiresConsultation(view.id, group.id, currentOptionId) : { required: false }}
{@const optionRef = group.values.find(v => v.id === currentOptionId)}
{@const hasPrice = optionRef?.amount && typeof optionRef.amount === 'number' && optionRef.amount > 0}
<div class="flex flex-col">
<span class="text-xs text-gray-500 uppercase tracking-wide">
{group.name || group.id}
</span>
<div class="flex items-center gap-2 justify-between">
<div>
<span class="text-sm font-medium text-gray-800">
{optionValue?.name || currentOptionId || 'No seleccionado'}
</span>
{#if requiresConsult.required}
<span class="text-xs text-orange-600" title={requiresConsult.message}>⚠️</span>
{/if}
{#if group.multiple}
<!-- Grupo de extras (selección múltiple): una línea por extra marcado -->
{@const selIds = appState.getSelectedIds(view.id, group.id)}
{#if selIds.length > 0}
<div class="flex flex-col">
<span class="text-xs text-gray-500 uppercase tracking-wide">
{group.name || group.id}
</span>
{#each selIds as optId}
{@const ov = appState.getOptionValue(optId)}
{@const ref = group.values.find(v => v.id === optId)}
{@const consult = appState.requiresConsultation(view.id, group.id, optId)}
{@const hasP = typeof ref?.amount === 'number' && ref.amount > 0}
<div class="flex items-center gap-2 justify-between">
<div>
<span class="text-sm font-medium text-gray-800">{ov?.name || optId}</span>
{#if consult.required}
<span class="text-xs text-orange-600" title={consult.message}>⚠️</span>
{/if}
</div>
{#if hasP}
<span class="text-xs font-semibold text-gray-800 whitespace-nowrap">{formatPrice(ref.amount)}</span>
{:else if ref?.amount === 0}
<span class="text-xs font-semibold text-gray-500 whitespace-nowrap">Sin coste</span>
{:else if ref?.amount === dataConfig.texts.with_cost}
<span class="text-xs font-semibold text-gray-800 whitespace-nowrap">{dataConfig.texts.with_cost}</span>
{/if}
</div>
{/each}
</div>
{#if hasPrice}
<span class="text-xs font-semibold text-gray-800 whitespace-nowrap">
{formatPrice(optionRef.amount)}
{/if}
{:else}
{@const currentOptionId = appState.getCurrentOption(view.id, group.id)}
{@const optionValue = currentOptionId ? appState.getOptionValue(currentOptionId) : null}
{@const requiresConsult = currentOptionId ? appState.requiresConsultation(view.id, group.id, currentOptionId) : { required: false }}
{@const optionRef = group.values.find(v => v.id === currentOptionId)}
{@const hasPrice = optionRef?.amount && typeof optionRef.amount === 'number' && optionRef.amount > 0}
<div class="flex flex-col">
<span class="text-xs text-gray-500 uppercase tracking-wide">
{group.name || group.id}
</span>
{:else if optionRef?.amount === dataConfig.texts.with_cost}
<div class="flex items-center gap-2 justify-between">
<div>
<span class="text-sm font-medium text-gray-800">
{optionValue?.name || currentOptionId || 'No seleccionado'}
</span>
{#if requiresConsult.required}
<span class="text-xs text-orange-600" title={requiresConsult.message}>⚠️</span>
{/if}
</div>
{#if hasPrice}
<span class="text-xs font-semibold text-gray-800 whitespace-nowrap">
{dataConfig.texts.with_cost}
{formatPrice(optionRef.amount)}
</span>
{/if}
{:else if optionRef?.amount === dataConfig.texts.with_cost}
<span class="text-xs font-semibold text-gray-800 whitespace-nowrap">
{dataConfig.texts.with_cost}
</span>
{/if}
</div>
</div>
</div>
{/if}
{/each}
{:else}
<div class="flex items-center justify-center h-full">

@ -172,6 +172,13 @@ export const dataConfig : DataConfig = {
image : undefined,
thumb : undefined,
},
eliminar_ducha: {
value : 'eliminar_ducha',
name : 'Eliminar ducha',
text : '',
image : undefined,
thumb : undefined,
},
// Mobiliario baño
mblanco: {
@ -188,19 +195,27 @@ export const dataConfig : DataConfig = {
image : 'muebles/mbp.jpg',
thumb : 'mbp_thumb.png',
},
mroble: {
value : 'mbr',
name : 'Mueble frontal roble',
text : 'Mueble frontal roble',
image : 'muebles/mbr.jpg',
thumb : 'mbr_thumb.png',
name : 'Mueble frontal roble natural',
text : 'Mueble frontal roble natural',
image : '',
thumb : 'mr_thumb.png',
},
mtostado: {
value : 'mbt',
name : 'Mueble frontal roble tostado',
text : 'Mueble frontal roble tostado',
image : 'muebles/mroble_tostado.jpg',
thumb : 'mbt_thumb.png',
image : '',
thumb : 'mt_thumb.png',
},
mwhite: {
value : 'mbb',
name : 'Mueble frontal blanco',
text : 'Mueble frontal blanco',
image : '',
thumb : 'mb_thumb.png',
},
@ -209,15 +224,15 @@ export const dataConfig : DataConfig = {
value : 'lavabof',
name : 'Lavabo flotante',
text : 'Lavabo flotante',
image : 'lavabos/lavabof.jpg',
thumb : 'lavabof_thumb.png',
image : '',
thumb : '',
},
lavabot: {
value : 'lavabot',
name : 'Lavabo en totem',
text : 'Lavabo en totem',
image : 'lavabos/lavabot.jpg',
thumb : 'lavabot_thumb.png',
image : '',
thumb : '',
},
@ -287,14 +302,14 @@ export const dataConfig : DataConfig = {
},
mab_mba: {
value : 'mab_mba',
mab_mbb: {
value : 'mab_mbb',
name : 'Muebles blancos',
text : 'Una cocina con muebles altos y bajos en blanco apuesta por la pureza y la luminosidad como protagonistas. La uniformidad cromática amplía visualmente el espacio y crea una sensación de orden, limpieza y serenidad.\n' +
'\n' +
'El blanco permite que la luz fluya sin obstáculos, potenciando una estética atemporal y versátil. Es una base perfecta para incorporar texturas, encimeras o detalles que aporten carácter sin perder la armonía del conjunto.',
image : 'mab_mba.jpg',
thumb : 'mab_mba_thumb.png',
image : 'mab_mbb.jpg',
thumb : 'mab_mbb_thumb.png',
},
mbpe: {
@ -347,6 +362,15 @@ export const dataConfig : DataConfig = {
thumb : 'enc_per_thumb.png',
},
// Extras
tendedero_per: {
value : 'tendedero_per',
name : 'Tendedero personalizado',
text : 'Tendedero personalizado. Coste según especificación, se presupuesta aparte.',
image : undefined, // OPCIONAL: si hay foto en img/mt/, activa el icono ⓘ + popover
thumb : undefined, // OPCIONAL: miniatura del botón
},
},
@ -427,7 +451,7 @@ export const dataConfig : DataConfig = {
name: 'Mueble de lavabo',
text: '',
help: '',
default: 'mbb',
default: 'mbr',
values : [
{ id: 'mbb'},
{ id: 'mbr'},
@ -516,8 +540,8 @@ export const dataConfig : DataConfig = {
help: '',
default: 'mbp',
values : [
{ id: 'mbp' },
{ id: 'mbb'},
{ id: 'mbb' },
{ id: 'mbp'},
],
},
@ -529,7 +553,7 @@ export const dataConfig : DataConfig = {
default: 'gn',
values : [
{ id: 'gn' },
{ id: 'gc', noRender: true },
{ id: 'gc', noRender: true, infoImage: 'hi/b2_trentino_light_banera_mbp_gc.jpg' },
],
},
@ -582,6 +606,23 @@ export const dataConfig : DataConfig = {
{ id: 'lavabot' },
],
},
// Extras del aseo (selección múltiple). "Eliminar ducha": coste 0
// e imagen visualizable en el popover (no cambia la imagen principal).
{
id: 'extras',
name: 'Extras',
text: '',
help: '',
multiple: true,
default: '',
values : [
{
id: 'eliminar_ducha',
amount: 0,
infoImage: 'hi/aseo_roble_trentino_lavabof_sinducha.jpg',
},
],
},
],
hotspots: [
// 🎯 HOTSPOT CONFIGURADO
@ -665,11 +706,30 @@ export const dataConfig : DataConfig = {
id: 'enpe',
amount: textWithCost,
warn: 'Coste según tipología y personalización, se contratará con Studio56',
rules: { views: { cocina: { mobiliario: { forbid :['mnaf','mvaf','mbmb'] } } } },
rules: { views: { cocina: { mobiliario: { allow :['mbpe'] } } } },
},
],
},
// Extras opcionales de la cocina (selección múltiple).
// El Tendedero no define imageToken -> no afecta a la imagen.
{
id: 'extras',
name: 'Extras',
text: '',
help: '',
multiple: true,
default: '',
values : [
{
id: 'tendedero_per',
amount: textWithCost,
noRender: true,
warn: 'El tendedero personalizado se presupuesta aparte; consulte con nuestro equipo.',
},
// Futuros extras: añadir aquí más { id, amount, ... }
],
},
],
hotspots: [
// 🎯 HOTSPOT CONFIGURADO

File diff suppressed because one or more lines are too long

@ -86,7 +86,7 @@ class DataConfigStore {
const view = dataConfig.options.views[viewId];
state.views[viewId] = {}
view.options.forEach((group: OptionGroup) => {
state.views[viewId][group.id] = group.default;
state.views[viewId][group.id] = group.multiple ? [] : group.default;
})
})
@ -166,6 +166,23 @@ class DataConfigStore {
}
}
/** Marcar/desmarcar una opción en un grupo de selección múltiple (extras) */
toggleOption(viewId: string, groupId: string, optionId: string) {
if (!(viewId in this.selection.views) || !(groupId in this.selection.views[viewId])) {
console.warn(`toggleOption: grupo ${groupId} no existe en vista ${viewId}`);
return;
}
const current = this.selection.views[viewId][groupId];
const list = Array.isArray(current) ? [...current] : [];
const idx = list.indexOf(optionId);
if (idx >= 0) list.splice(idx, 1);
else list.push(optionId);
this.selection.views[viewId][groupId] = list;
this.debugLog('toggleOption', { viewId, groupId, optionId, selected: list });
this.revalidateView(viewId);
}
/** Resetear todo o una vista específica */
reset(viewId?: string) {
if (viewId) {
@ -175,7 +192,7 @@ class DataConfigStore {
return;
}
view.options.forEach((group: OptionGroup) => {
this.selection.views[viewId][group.id] = group.default;
this.selection.views[viewId][group.id] = group.multiple ? [] : group.default;
});
this.debugLog('reset', { viewId, type: 'single' });
@ -219,6 +236,14 @@ class DataConfigStore {
if (viewConfig?.options) {
viewConfig.options.forEach((group: OptionGroup) => {
const val = this.selection.views[viewId][group.id];
if (Array.isArray(val)) {
// Grupo multi (extras): añadir el token de imagen de cada extra
// marcado que lo defina (en orden de definición, determinista).
for (const ref of group.values) {
if (val.includes(ref.id) && ref.imageToken) parts.push(ref.imageToken);
}
return;
}
if (!val) return;
// Si la opción seleccionada no tiene representación en la vista,
// se usa el token por defecto del grupo para no romper la imagen.
@ -274,12 +299,22 @@ class DataConfigStore {
};
}
/** Obtener el valor actual de una opción */
/** Obtener el valor actual de una opción (grupos de selección única) */
getCurrentOption(viewId: string | 'general', groupId: string): string | undefined {
if (viewId === 'general') {
return this.selection.general[groupId];
}
return this.selection.views[viewId]?.[groupId];
const val = this.selection.views[viewId]?.[groupId];
return Array.isArray(val) ? undefined : val;
}
/** Obtener los ids seleccionados de un grupo como lista (single -> [x], multi -> [...]) */
getSelectedIds(viewId: string | 'general', groupId: string): string[] {
const val = viewId === 'general'
? this.selection.general[groupId]
: this.selection.views[viewId]?.[groupId];
if (Array.isArray(val)) return val;
return val ? [val] : [];
}
/** Obtener todas las vistas disponibles */
@ -313,7 +348,7 @@ class DataConfigStore {
if (rules.views) {
for (const [viewId, groupConstraints] of Object.entries(rules.views)) {
for (const [groupId, constraint] of Object.entries(groupConstraints)) {
const currentVal = this.selection.views[viewId]?.[groupId];
const currentVal = this.getCurrentOption(viewId, groupId);
if (!currentVal) continue;
if (constraint.forbid?.includes(currentVal)) return false;
if (constraint.allow && !constraint.allow.includes(currentVal)) return false;
@ -345,7 +380,23 @@ class DataConfigStore {
const changes: any[] = [];
view.options.forEach((group: OptionGroup) => {
const currentId = this.selection.views[viewId][group.id];
// Grupos de selección múltiple (extras): quitar de la selección los que
// dejen de ser válidos según sus rules.
if (group.multiple) {
const sel = this.selection.views[viewId][group.id];
if (Array.isArray(sel)) {
const valid = sel.filter(id => {
const opt = group.values.find(v => v.id === id);
return opt ? this.isValid(opt.rules) : false;
});
if (valid.length !== sel.length) {
changes.push({ group: group.id, from: sel, to: valid, reason: 'extra rule invalid' });
this.selection.views[viewId][group.id] = valid;
}
}
return;
}
const currentId = this.getCurrentOption(viewId, group.id);
const currentOpt = group.values.find(v => v.id === currentId);
// Si la opción actual no es válida
@ -420,7 +471,15 @@ export function getViewImage(viewId: string): string {
if (viewConfig?.options) {
viewConfig.options.forEach((group) => {
const value = appState.selection.views[viewId]?.[group.id];
if (value) parts.push(value);
if (Array.isArray(value)) {
for (const ref of group.values) {
if (value.includes(ref.id) && ref.imageToken) parts.push(ref.imageToken);
}
return;
}
if (!value) return;
const ref = group.values.find(v => v.id === value);
parts.push(ref?.noRender ? group.default : value);
});
}
@ -438,9 +497,8 @@ export function getViewTotal(view : View): number {
let total = 0;
for (const group of view.options) {
const currentOptionId = appState.getCurrentOption(view.id, group.id);
if (currentOptionId) {
const option = group.values.find(v => v.id === currentOptionId);
for (const optionId of appState.getSelectedIds(view.id, group.id)) {
const option = group.values.find(v => v.id === optionId);
if (option?.amount && typeof option.amount === 'number') {
total += option.amount;
}
@ -454,9 +512,8 @@ export function getViewTotal(view : View): number {
// Verificar si hay incidencias en esta vista
export function hasIncidencias(view: View): boolean {
for (const group of view.options) {
const currentOptionId = appState.getCurrentOption(view.id, group.id);
if (currentOptionId) {
const consultaInfo = appState.requiresConsultation(view.id, group.id, currentOptionId);
for (const optionId of appState.getSelectedIds(view.id, group.id)) {
const consultaInfo = appState.requiresConsultation(view.id, group.id, optionId);
if (consultaInfo.required) return true;
}
}
@ -473,25 +530,23 @@ export function getIncidencias(): Record<string, Incidencia[]> {
const view = dataConfig.options.views[viewId];
for (const group of view.options) {
const currentOptionId = appState.getCurrentOption(viewId, group.id);
if (currentOptionId) {
const consultaInfo = appState.requiresConsultation(viewId, group.id, currentOptionId);
for (const optionId of appState.getSelectedIds(viewId, group.id)) {
const consultaInfo = appState.requiresConsultation(viewId, group.id, optionId);
if (consultaInfo.required) {
if (!incidenciasPorView[viewId]) {
incidenciasPorView[viewId] = [];
}
const optionValue = appState.getOptionValue(currentOptionId);
const optionValue = appState.getOptionValue(optionId);
incidenciasPorView[viewId].push({
viewId,
viewName: view.name || view.id,
groupId: group.id,
groupName: group.name || group.id,
optionId: currentOptionId,
optionName: optionValue?.name || currentOptionId,
optionId: optionId,
optionName: optionValue?.name || optionId,
mensaje: consultaInfo.message || 'Esta opción requiere consulta'
});
}
@ -512,9 +567,8 @@ export function getTotalView(viewId: string): number {
}
for (const group of view.options) {
const currentOptionId = appState.getCurrentOption(viewId, group.id);
if (currentOptionId) {
const option = group.values.find(v => v.id === currentOptionId);
for (const optionId of appState.getSelectedIds(viewId, group.id)) {
const option = group.values.find(v => v.id === optionId);
if (option?.amount && typeof option.amount === 'number') {
total += option.amount;
}

@ -12,8 +12,10 @@ export interface AppState {
/** Opciones globales aplicables a todas las vistas */
general: Record<string, string>;
/** Opciones específicas por vista */
views: Record<string, Record<string, string>>;
/** Opciones específicas por vista.
* El valor es `string` para grupos normales y `string[]` para grupos
* de selección múltiple (extras). */
views: Record<string, Record<string, string | string[]>>;
}
/**

@ -27,11 +27,23 @@ export interface OptionReference {
/** Reglas de validación */
rules?: ValidationRules;
/**
* La opción no tiene representación en la imagen de la vista.
* La imagen principal usa el token por defecto del grupo (evita 404) y,
* al seleccionarla, se muestra un popover con su foto/texto de producto.
* La opción no tiene representación en la imagen de la vista:
* la imagen principal usa el token por defecto del grupo (evita 404)
* y en la opción aparece un icono de información.
*/
noRender?: boolean;
/**
* Imagen a mostrar en el popover al pulsar el icono de información.
* Ruta relativa a `sources.images` (p. ej. 'hi/b2_..._gc.jpg').
* Si no se define, se usa la imagen de producto del valor.
*/
infoImage?: string;
/**
* (Solo extras/multi) Token que este extra añade al nombre de imagen de la
* vista cuando está marcado (p. ej. 'sinducha'). Si no se define, el extra
* no afecta a la imagen (p. ej. Tendedero).
*/
imageToken?: string;
}
/**
@ -46,6 +58,8 @@ export interface OptionGroup {
/** Advertencia a nivel de grupo */
warn?: string;
values: OptionReference[];
/** Grupo de selección múltiple (extras / add-ons): se pueden marcar varias. */
multiple?: boolean;
}
// ============================================================================

Loading…
Cancel
Save

Powered by TurnKey Linux.