dev 8 months ago
parent e4cb3145fa
commit 490b766a74

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

@ -0,0 +1,79 @@
<script lang="ts">
/**
* Badge.svelte
* Componente reutilizable para mostrar badges con iconos
*/
interface Props {
type?: 'warning' | 'success' | 'error' | 'info' | 'disabled';
size?: 'sm' | 'md' | 'lg';
position?: 'top-right' | 'top-right-i' | 'top-left' | 'bottom-right' | 'bottom-left';
customIcon?: 'snippet'; // Para pasar HTML del icono
}
let {
type = 'warning',
size = 'md',
position = 'top-right',
customIcon
}: Props = $props();
// Configuración de tamaños
const sizes = {
sm: { container: 'w-6 h-6', icon: 'w-4 h-4' },
md: { container: 'w-8 h-8', icon: 'w-6 h-6' },
lg: { container: 'w-10 h-10', icon: 'w-8 h-8' }
};
// Configuración de colores
const colors = {
warning: 'bg-yellow-400',
success: 'bg-green-500',
error: 'bg-red-500',
info: 'bg-blue-500',
disabled: 'bg-gray-400'
};
// Configuración de posiciones
const positions = {
'top-right': '-top-2 -right-2',
'top-right-i': 'top-1 right-1',
'top-left': '-top-2 -left-2',
'bottom-right': '-bottom-2 -right-2',
'bottom-left': '-bottom-2 -left-2'
};
// Iconos por tipo
const icons = {
warning: `<svg class="${sizes[size].icon} text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>`,
success: `<svg class="${sizes[size].icon} text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>`,
error: `<svg class="${sizes[size].icon} text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>`,
info: `<svg class="${sizes[size].icon} text-white" fill="currentColor" viewBox="0 0 20 20">
<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>`,
disabled: `<svg class="${sizes[size].icon} text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M13.477 14.89A6 6 0 015.11 6.524l8.367 8.368zm1.414-1.414L6.524 5.11a6 6 0 018.367 8.367zM18 10a8 8 0 11-16 0 8 8 0 0116 0z" clip-rule="evenodd"/>
</svg>`
};
const iconHtml = customIcon || icons[type];
</script>
<div
class="absolute {positions[position]} {sizes[size].container} {colors[type]} rounded-full flex items-center justify-center shadow-md z-10"
>
{@html iconHtml}
</div>
<style>
/* Asegurar que el badge esté siempre visible */
div {
pointer-events: auto;
}
</style>

@ -4,6 +4,7 @@
import {dataConfig} from "../data/DataConfig";
import {formatPrice} from "../utils/formats";
import {popupMessage} from "../data/PopsState.svelte";
import Badget from "./Badget.svelte";
const imgPath : string = dataConfig.sources.imgValues
@ -76,35 +77,18 @@
>
<!-- Warning indicator (simple) -->
{#if consultation.message}
<div class="absolute -top-3 -left-1 w-5 h-5 rounded-full flex items-center justify-center text-white text-s font-bold shadow-md p-3">
⚠
</div>
<Badget type="warning" size="sm" position="top-left"/>
{/if}
<!-- Selected indicator (simple) -->
{#if isSelected}
<div class="absolute -top-1 {consultation.message ? '-right-1' : '-right-1'} w-5 h-5 bg-green-600 rounded-full flex items-center justify-center text-white text-xs font-bold shadow-md">
✓
</div>
<Badget type="success" size="sm" position="top-right"/>
{/if}
<!-- Imagen -->
{#if optionValue?.thumb}
<img
class="h-20 object-cover mb-2"
src={imgOptSrc}
alt={optionValue.name}
/>
{:else}
<!--
<div class="w-full h-20 bg-gray-100 rounded mb-2 flex items-center justify-center">
<span class="text-gray-400 text-xs">Sin imagen</span>
</div>
-->
<img class="h-20 object-cover mb-2" src={imgOptSrc} alt={optionValue.name} />
{/if}
<!-- Nombre -->
@ -112,29 +96,11 @@
{optionValue?.name || optionRef.id}
</div>
<!-- ✅ Badge "No disponible" para opciones deshabilitadas -->
{#if isDisabled}
<div class="absolute -top-2 -right-1 w-5 h-5 rounded-full flex items-center justify-center text-white text-s font-bold shadow-md">
🚫
</div>
<!--
<div class="absolute top-1 right-1 bg-red-500 text-white px-2 py-0.5 rounded-full text-xs font-semibold">
🚫
</div>
-->
<Badget type="disabled" size="sm" position="top-right"/>
{/if}
<!-- Texto adicional -->
<!--
{#if optionValue?.text}
<div class="text-xs text-gray-600 mb-1">
{optionValue.text}
</div>
{/if}
-->
<!-- Precio -->
{#if hasPrice}
<div class="text-center mt-1">
@ -145,21 +111,4 @@
{/if}
<!-- Badge consultar -->
<!--
{#if consultation.required}
<span class="inline-block px-2 py-0.5 bg-yellow-100 text-yellow-800 text-xs rounded mt-1">
Consultar
</span>
{/if}
-->
</button>
<!-- Advertencia -->
<!--
{#if consultation.message && isSelected}
<div class="col-span-2 p-3 bg-yellow-50 border-l-4 border-yellow-400 text-xs text-yellow-800 rounded">
⚠️ {consultation.message}
</div>
{/if}
-->

@ -1,165 +0,0 @@
<script lang="ts">
import { appState } from "../data/AppState.svelte";
import type { OptionReference } from "../data/DataConfigTypes";
import {dataConfig} from "../data/DataConfig";
import {formatPrice} from "../utils/formats";
import {popupMessage} from "../data/PopsState.svelte";
const imgPath : string = dataConfig.sources.imgValues
let {
optionRef,
groupId,
viewId,
isDisabled = false,
isSelected = false
} = $props<{
optionRef: OptionReference;
groupId: string;
viewId: string;
isDisabled?: boolean;
isSelected?: boolean;
}>();
const textWithCost = dataConfig.texts.with_cost;
// Forzar que estos sean reactivos
const optionValue = $derived(appState.getOptionValue(optionRef.id));
const consultation = $derived(appState.requiresConsultation(viewId, groupId, optionRef.id));
const imgOptSrc = $derived(imgPath + optionValue.thumb)
// Añadir log solo cuando isSelected cambia
$effect(() => {
if (isSelected) {
console.log(`✓ ${optionRef.id} SELECCIONADO en ${viewId}/${groupId}`);
}
});
function handleClick() {
if (isSelected) {
return;
}
console.log('🔵 CLICK:', { viewId, groupId, optionId: optionRef.id });
if (consultation.message) {
popupMessage.open(consultation.message);
}
appState.setOption(viewId, groupId, optionRef.id);
}
// Obtener el precio desde optionRef.amount
const hasPrice = $derived(
optionRef.amount !== undefined &&
optionRef.amount === textWithCost || (
typeof optionRef.amount === 'number' &&
optionRef.amount > 0)
);
</script>
<button
class="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}
onclick={handleClick}
>
<!-- Warning indicator (simple) -->
{#if consultation.message}
<div class="absolute -top-3 -left-1 w-5 h-5 rounded-full flex items-center justify-center text-white text-s font-bold shadow-md p-3">
⚠
</div>
{/if}
<!-- Selected indicator (simple) -->
{#if isSelected}
<div class="absolute -top-1 {consultation.message ? '-right-1' : '-right-1'} w-5 h-5 bg-green-600 rounded-full flex items-center justify-center text-white text-xs font-bold shadow-md">
✓
</div>
{/if}
<!-- Imagen -->
{#if optionValue?.thumb}
<img
class="h-20 object-cover mb-2"
src={imgOptSrc}
alt={optionValue.name}
/>
{:else}
<!--
<div class="w-full h-20 bg-gray-100 rounded mb-2 flex items-center justify-center">
<span class="text-gray-400 text-xs">Sin imagen</span>
</div>
-->
{/if}
<!-- Nombre -->
<div class="font-medium text-xs mb-1 text-center">
{optionValue?.name || optionRef.id}
</div>
<!-- ✅ Badge "No disponible" para opciones deshabilitadas -->
{#if isDisabled}
<div class="absolute -top-2 -right-1 w-5 h-5 rounded-full flex items-center justify-center text-white text-s font-bold shadow-md">
🚫
</div>
<!--
<div class="absolute top-1 right-1 bg-red-500 text-white px-2 py-0.5 rounded-full text-xs font-semibold">
🚫
</div>
-->
{/if}
<!-- Texto adicional -->
<!--
{#if optionValue?.text}
<div class="text-xs text-gray-600 mb-1">
{optionValue.text}
</div>
{/if}
-->
<!-- Precio -->
{#if hasPrice}
<div class="text-center mt-1">
<span class="w-full inline-block bg-gray-200 px-2 py-1 text-gray-800 text-xm font-semibold rounded">
+ {formatPrice(optionRef.amount)}
</span>
</div>
{/if}
<!-- Badge consultar -->
<!--
{#if consultation.required}
<span class="inline-block px-2 py-0.5 bg-yellow-100 text-yellow-800 text-xs rounded mt-1">
Consultar
</span>
{/if}
-->
</button>
<!-- Advertencia -->
<!--
{#if consultation.message && isSelected}
<div class="col-span-2 p-3 bg-yellow-50 border-l-4 border-yellow-400 text-xs text-yellow-800 rounded">
⚠️ {consultation.message}
</div>
{/if}
-->

@ -12,7 +12,7 @@
</script>
<div class="border border-gray-200 rounded-xl mb-6 bg-white shadow-sm overflow-hidden">
<div class="border border-gray-200 rounded-xl mb-2 bg-white shadow-sm overflow-hidden">
<button
type="button"
onclick={() => openHandler()}
@ -32,7 +32,7 @@
</button>
{#if isOpen}
<div class="p-5 border-t border-gray-100 bg-white">
<div class="p-2 border-t border-gray-100 bg-white">
{@render children()}
</div>
{/if}

@ -140,7 +140,7 @@
>
<!-- Header -->
<div class="bg-gradient-to-r from-green-600 to-green-700 text-white p-3 relative">
<div class="bg-green-800 to-green-700 text-white p-3 relative">
<button
class="absolute top-4 right-4 text-white hover:text-gray-200 transition-colors"
onclick={onClose}
@ -156,7 +156,7 @@
</h2>
{#if hotspot.groupType === 'general'}
<span class="inline-block px-2 py-1 bg-green-500 bg-opacity-30 rounded text-xs font-semibold mt-2">
<span class="inline-block px-2 py-1 bg-opacity-30 rounded text-xm font-semibold mt-2">
Opción general para toda la vivienda
</span>
{/if}

@ -45,15 +45,6 @@
{/each}
<!-- ✅ Mensaje de consulta si la opción seleccionada lo requiere -->
<!--
{#if selectedConsultation?.required}
<div class="col-span-2 p-3 bg-yellow-50 border-l-4 border-yellow-400 text-xs text-yellow-800 rounded">
⚠️ {selectedConsultation.message}
</div>
{/if}
-->
</div>
</div>

@ -0,0 +1,54 @@
<script lang="ts">
import type {OptionGroup} from "../data/DataConfigTypes";
import {appState} from "../data/AppState.svelte";
import BtOption from "./BtOption.svelte";
import {derived} from "svelte/store";
let {
group,
viewId = appState.selection.currentViewId
} = $props<{
group: OptionGroup;
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);
});
</script>
<div class="p-0" >
<!--
<h4 class="text-sm font-semibold text-gray-800 mb-3">{group.name || 'Opciones' }</h4>
-->
<p class="text-sl mb-4">
El pavimento de la vivienda es el mismo en todas las estancias, por lo que la opción elegida será la que se instale también en el resto de la vivienda.
</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}
</div>
</div>

@ -0,0 +1,88 @@
<script lang="ts">
import { appState } from '../data/AppState.svelte';
import { dataConfig } from '../data/DataConfig';
import type { View } from '../data/DataConfigTypes';
import {formatPrice} from "../utils/formats";
import Badget from "./Badget.svelte";
import {handleImageStyleError} from "../utils/uix";
import {navigationState} from "../data/NavState.svelte";
// Props
/*
interface Props {
view: View;
onClick?: (viewId: string) => void;
}
let { view, onClick }: Props = $props();
*/
function handleClick() {
appState.setView('suelo');
navigationState.activePage = 'cfg'; // Navegar al configurador
}
</script>
<div
class="bg-white rounded-xl border border-gray-300 shadow-sm overflow-hidden hover:shadow-lg transition-all duration-200 cursor-pointer group"
role="button"
tabindex="0"
onclick={handleClick}
onkeydown={(e) => e.key === 'Enter' && handleClick()}
>
<!-- Título de la vista -->
<div class="bg-green-900 text-white p-2">
<h4 class="text-lg font-semibold">
Opciones Generales
</h4>
</div>
<!-- Imagen de la vista -->
<div class="relative h-48 bg-gray-100 overflow-hidden">
<img
src={appState.imgSrc + 'edificio.jpg'}
alt="Pavimento General"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
</div>
<!-- Lista de selecciones -->
<div class="p-4 space-y-2 text-left min-h-[180px]">
{#each Object.entries(dataConfig.options.general) as [key, group]}
{@const currentOptionId = appState.getCurrentOption('general', key)}
{@const optionValue = currentOptionId ? appState.getOptionValue(currentOptionId) : null}
<div class="flex flex-col p-2">
<span class="text-xs text-gray-500 uppercase tracking-wider mb-2 font-semibold">
{group.name || group.id}
</span>
<span class="text-base font-semibold text-gray-800 mb-3">
{optionValue?.name || currentOptionId || 'No seleccionado'}
</span>
{#if optionValue?.thumb}
<img
src={dataConfig.sources.imgValues + optionValue.image}
alt={optionValue.name}
class="w-full h-12 object-cover rounded-md shadow-sm"
onerror={handleImageStyleError}
/>
{/if}
</div>
{/each}
</div>
</div>

@ -1,7 +1,9 @@
<script lang="ts">
import { appState } from '../data/AppState.svelte';
import {appState, getViewImageUrl, getViewTotal, hasIncidencias} from '../data/AppState.svelte';
import { dataConfig } from '../data/DataConfig';
import type { View } from '../data/DataConfigTypes';
import {formatPrice} from "../utils/formats";
import Badget from "./Badget.svelte";
// Props
interface Props {
@ -11,66 +13,7 @@
let { view, onClick }: Props = $props();
// Función para obtener la URL de la imagen de la vista
function getViewImageUrl(viewId: string): string {
const parts: string[] = [viewId];
// Agregar opciones generales
Object.keys(dataConfig.options.general).forEach(genKey => {
const value = appState.selection.general[genKey];
if (value) parts.push(value);
});
// Agregar opciones específicas de la vista
const viewConfig = dataConfig.options.views[viewId];
if (viewConfig?.options) {
viewConfig.options.forEach((group) => {
const value = appState.selection.views[viewId]?.[group.id];
if (value) parts.push(value);
});
}
return appState.imgSrc + `${parts.join('_')}.jpg`;
}
// Función para calcular el total de la vista
function getViewTotal(): 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);
if (option?.amount && typeof option.amount === 'number') {
total += option.amount;
}
}
}
return total;
}
// Función para formatear precio
function formatearPrecio(cantidad: number): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(cantidad);
}
// Verificar si hay incidencias en esta vista
function hasIncidencias(): 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);
if (consultaInfo.required) return true;
}
}
return false;
}
// Handler de click
function handleClick() {
@ -83,44 +26,46 @@
// Valores derivados reactivos
let imageUrl = $derived(getViewImageUrl(view.id));
let total = $derived(getViewTotal());
let hasWarnings = $derived(hasIncidencias());
let total = $derived(getViewTotal(view));
let hasWarnings = $derived(hasIncidencias(view));
</script>
<div
class="bg-white rounded-xl border border-gray-300 shadow-sm overflow-hidden hover:shadow-lg transition-all duration-200 cursor-pointer group"
class:border-red-300={hasWarnings}
class:border-orange-300={hasWarnings}
role="button"
tabindex="0"
onclick={handleClick}
onkeydown={(e) => e.key === 'Enter' && handleClick()}
>
<!-- Imagen de la vista -->
<div class="relative h-48 bg-gray-100 overflow-hidden">
<img
src={imageUrl}
alt={view.name || view.id}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<!-- Título de la vista -->
<div class="bg-green-800 text-white p-2">
<h4 class="text-lg font-semibold">
{view.name || view.id}
</h4>
<div class="relative">
<!-- Indicador de incidencias -->
{#if hasWarnings}
<div class="absolute top-2 right-2 bg-red-500 text-white px-2 py-1 rounded-full text-xs font-semibold shadow-lg">
⚠️ Revisar
</div>
<Badget type="warning" size="md" position="top-right-i" />
{/if}
</div>
</div>
<!-- Título de la vista -->
<div class="bg-green-800 text-white p-3">
<h3 class="text-lg font-semibold">
{view.name || view.id}
</h3>
<!-- Imagen de la vista -->
<div class="relative h-48 bg-gray-100 overflow-hidden">
<img
src={imageUrl}
alt={view.name || view.id}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
</div>
<!-- Lista de selecciones -->
<div class="p-4 space-y-2 text-left min-h-[180px]">
{#if view.options.length > 0}
@ -128,17 +73,30 @@
{@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-1">
<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-red-600" title={requiresConsult.message}>⚠️</span>
<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">
{formatPrice(optionRef.amount)}
</span>
{: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>
@ -152,12 +110,12 @@
<!-- Total de la vista (si hay precios) -->
{#if total > 0}
<div class="px-4 pb-4">
<div class="px-4 pb-4 bg-gray-50">
<div class="pt-3 border-t border-gray-200">
<div class="flex justify-between items-center">
<span class="text-sm font-semibold text-gray-700">Extras:</span>
<span class="text-sm font-semibold text-gray-700">Total opciones</span>
<span class="text-base font-bold text-green-700">
{formatearPrecio(total)}
{formatPrice(total)}
</span>
</div>
</div>

@ -1,6 +1,6 @@
import { dataConfig } from './DataConfig';
import type {DataConfig, Rules, OptionGroup, View} from './DataConfigTypes';
import type { AppState } from './AppStateTypes';
import type {AppState, Incidencia} from './AppStateTypes';
class DataConfigStore {
@ -386,4 +386,155 @@ class DataConfigStore {
}
}
export const appState = new DataConfigStore();
// Función para obtener la URL de la imagen de la vista
export function getViewImageUrl(viewId: string): string {
const parts: string[] = [viewId];
// Agregar opciones generales
Object.keys(dataConfig.options.general).forEach(genKey => {
const value = appState.selection.general[genKey];
if (value) parts.push(value);
});
// Agregar opciones específicas de la vista
const viewConfig = dataConfig.options.views[viewId];
if (viewConfig?.options) {
viewConfig.options.forEach((group) => {
const value = appState.selection.views[viewId]?.[group.id];
if (value) parts.push(value);
});
}
return appState.imgSrc + `${parts.join('_')}.jpg`;
}
// Función para calcular el total de la vista
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);
if (option?.amount && typeof option.amount === 'number') {
total += option.amount;
}
}
}
return total;
}
// 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);
if (consultaInfo.required) return true;
}
}
return false;
}
// Función para obtener las incidencias
export function getIncidencias(): Record<string, Incidencia[]> {
const incidenciasPorView: Record<string, Incidencia[]> = {};
for (const viewId of appState.getAllViewIds()) {
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);
if (consultaInfo.required) {
if (!incidenciasPorView[viewId]) {
incidenciasPorView[viewId] = [];
}
const optionValue = appState.getOptionValue(currentOptionId);
incidenciasPorView[viewId].push({
viewId,
viewName: view.name || view.id,
groupId: group.id,
groupName: group.name || group.id,
optionId: currentOptionId,
optionName: optionValue?.name || currentOptionId,
mensaje: consultaInfo.message || 'Esta opción requiere consulta'
});
}
}
}
}
return incidenciasPorView;
}
// Función para calcular el total de una vista
export function getTotalView(viewId: string): number {
let total = 0;
const view = dataConfig.options.views[viewId];
for (const group of view.options) {
const currentOptionId = appState.getCurrentOption(viewId, group.id);
if (currentOptionId) {
const option = group.values.find(v => v.id === currentOptionId);
if (option?.amount && typeof option.amount === 'number') {
total += option.amount;
}
}
}
return total;
}
// Función para calcular el total general
export function getTotalGeneral(): number {
let total = 0;
for (const viewId of appState.getAllViewIds()) {
total += getTotalView(viewId);
}
return total;
}

@ -19,3 +19,16 @@ export interface AppState {
*/
views: Record<string, Record<string, string>>;
}
// Tipos para incidencias
export interface Incidencia {
viewId: string;
viewName: string;
groupId: string;
groupName: string;
optionId: string;
optionName: string;
mensaje: string;
}

@ -470,7 +470,7 @@ export const dataConfig : DataConfig = {
{
groupId: 'alicatado',
groupType: 'view',
x: 72,
x: 92,
y: 55,
},
],
@ -625,5 +625,3 @@ export const dataConfig : DataConfig = {
}

@ -0,0 +1,8 @@
// uix.ts
export function handleImageStyleError(e: Event) {
const target = e.currentTarget as HTMLImageElement;
target.onerror = null; // evita bucle
target.style.display = 'none';
}

@ -7,6 +7,7 @@
import BtViewAccordion from "../cmps/BtViewAccordion.svelte";
import {navigationState} from "../data/NavState.svelte";
import Hotspots from "../cmps/Hotspots.svelte";
import PnGroupGeneral from "../cmps/PnGroupGeneral.svelte";
let isLoading = $state(true);
@ -50,42 +51,6 @@
<div class="bg-gray-50 flex flex-col lg:flex-row min-h-[calc(100vh-4rem)] items-start lg:fixed lg:inset-x-0 lg:bottom-0 lg:top-16">
<!-- IMAGEN CENTRAL -->
<!--
<div class="sticky top-1 z-10 flex-1 p-3 h-full bg-white shadow-lg lg:static flex lg:items-start justify-center overflow-hidden">
<div class="relative w-full grid grid-cols-1 grid-rows-1 items-center justify-items-center">
{#if isLoading}
<div
class="col-start-1 row-start-1 z-20 flex items-center justify-center p-10 bg-white/50 backdrop-blur-sm rounded-xl"
transition:fade={{ duration: 200 }}
>
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
<p class="ml-4 text-gray-600 font-medium">{ dataConfig.texts.loading_view || 'Cargando...' }</p>
</div>
{/if}
{#if hasError}
<div class="col-start-1 row-start-1 z-20 text-center p-10 bg-red-50 rounded-lg">
<p class="text-red-600 text-lg mb-2">{ dataConfig.texts.errorLoadingImage || 'Error' }</p>
</div>
{:else if imageUrl}
{#key transitionKey}
<img
src={imageUrl}
alt="Vista estancia"
class="col-start-1 row-start-1 w-full h-auto max-h-[90vh] object-contain shadow-sm"
transition:fade={{ duration: 500 }}
/>
{/key}
{/if}
</div>
</div>
-->
<!-- IMAGEN CENTRAL CON HOTSPOTS -->
<div class="sticky top-1 z-10 flex-1 p-3 h-full bg-white shadow-lg lg:static flex lg:items-start justify-center overflow-hidden">
<div class="relative w-full h-full flex items-center justify-center">
@ -97,27 +62,21 @@
</div>
<!-- ASIDE DERECHA CON BARRA SCROOL -->
<div class="flex-1 lg:flex-none lg:w-[380px] flex flex-col bg-gray-50 border-gray-200 overflow-y-auto lg:h-full">
<div class="w-full bg-white shadow-xl border-l border-gray-200 flex flex-col h-full overflow-hidden">
<div class="p-4 border-b w border-gray-300 bg-gray-50">
<div class="p-3 border-b w border-gray-300 bg-gray-50">
<BtViewAccordion
isOpen={navigationState.isOpenGeneral}
openHandler={navigationState.turnOpenGeneral}
title={dataConfig.texts.title_options_general || 'PAVIMENTO GENERAL DE LA VIVIENDA'}
desc={dataConfig.texts.text_options_general || 'El pavimento de la vivienda es el mismo en todas las estancias, por lo que la opción elegida será la que se instale también en el resto de la vivienda.'}
desc=''
>
{#each Object.values(dataConfig.options.general) as group (group.id)}
<div class="mb-8 last:mb-0">
<PnGroup {group} viewId="general" />
<PnGroupGeneral {group} viewId="general" />
</div>
{/each}
</BtViewAccordion>

@ -1,10 +1,8 @@
<script lang="ts">
import {appState} from "../data/AppState.svelte";
import {dataConfig} from "../data/DataConfig";
import { fade } from 'svelte/transition';
import { onMount, onDestroy } from 'svelte';
let isLoading = $state(false);
const images = dataConfig.gallery;
const pathImg = dataConfig.sources.imgGallery;

@ -1,8 +1,10 @@
<script lang="ts">
import { appState } from '../data/AppState.svelte';
import {appState, getIncidencias, getTotalGeneral, getTotalView} from '../data/AppState.svelte';
import { dataConfig } from '../data/DataConfig';
import PnView from '../cmps/PnView.svelte';
import type { View } from '../data/DataConfigTypes';
import {formatPrice} from "../utils/formats";
import PnSelGeneral from "../cmps/PnSelGeneral.svelte";
import PnSelView from "../cmps/PnSelView.svelte";
import {handleImageStyleError} from "../utils/uix";
// Props opcionales
interface Props {
@ -15,106 +17,43 @@
showGeneralOptions = true
}: Props = $props();
// Tipos para incidencias
interface Incidencia {
viewId: string;
viewName: string;
groupId: string;
groupName: string;
optionId: string;
optionName: string;
mensaje: string;
}
// Función para obtener las incidencias
function getIncidencias(): Record<string, Incidencia[]> {
const incidenciasPorView: Record<string, Incidencia[]> = {};
for (const viewId of appState.getAllViewIds()) {
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);
if (consultaInfo.required) {
if (!incidenciasPorView[viewId]) {
incidenciasPorView[viewId] = [];
}
const optionValue = appState.getOptionValue(currentOptionId);
incidenciasPorView[viewId].push({
viewId,
viewName: view.name || view.id,
groupId: group.id,
groupName: group.name || group.id,
optionId: currentOptionId,
optionName: optionValue?.name || currentOptionId,
mensaje: consultaInfo.message || 'Esta opción requiere consulta'
});
}
}
}
// Handler para click en vista
function handleViewClick(viewId: string) {
if (onViewClick) {
onViewClick(viewId);
} else {
appState.setView(viewId);
}
return incidenciasPorView;
}
// Función para calcular el total de una vista
function getTotalView(viewId: string): number {
let total = 0;
const view = dataConfig.options.views[viewId];
for (const group of view.options) {
const currentOptionId = appState.getCurrentOption(viewId, group.id);
if (currentOptionId) {
const option = group.values.find(v => v.id === currentOptionId);
if (option?.amount && typeof option.amount === 'number') {
total += option.amount;
}
}
}
return total;
}
function scrollTo(event, id) {
// 1. Evitamos que el router de SvelteKit capture el clic
event.preventDefault();
// Función para calcular el total general
function getTotalGeneral(): number {
let total = 0;
for (const viewId of appState.getAllViewIds()) {
total += getTotalView(viewId);
// 2. Hacemos el scroll manual
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
// 3. Opcional: Actualizar la URL manualmente
history.pushState(null, null, `#${id}`);
}
return total;
}
// Formatear precio
function formatearPrecio(cantidad: number): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(cantidad);
}
// Handler para click en vista
function handleViewClick(viewId: string) {
if (onViewClick) {
onViewClick(viewId);
} else {
appState.setView(viewId);
}
}
// Valores derivados reactivos
let incidencias = $derived(getIncidencias());
let totalGeneral = $derived(getTotalGeneral());
let hasIncidencias = $derived(Object.keys(incidencias).length > 0);
</script>
<div class="mx-auto text-center bg-white bg-cover bg-center h-full overflow-y-auto">
<div class="max-w-7xl w-full mx-auto pt-4 pb-8">
@ -128,72 +67,78 @@
<div class="p-4 sm:p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<PnSelGeneral />
<!-- FICHAS DE VISTAS -->
{#each appState.getAllViews() as view}
<PnView {view} onClick={handleViewClick} />
<PnSelView {view} onClick={handleViewClick} />
{/each}
<!-- INCIDENCIAS -->
{#if hasIncidencias}
<div class="col-span-1 md:col-span-2 lg:col-span-3">
<div class="bg-white rounded-xl border border-red-200 shadow-sm overflow-hidden">
<div class="p-6 text-left">
<h2 class="text-lg font-bold text-red-700 mb-4 flex items-center gap-2">
<span class="text-2xl">⚠️</span>
<span>Opciones que requieren consulta</span>
</h2>
<!-- OTROS COSTES -->
<div class="col-span-1">
<div class="bg-gray-100 rounded-xl border border-gray-500 overflow-hidden flex flex-col sticky top-4 h-full">
{#each Object.entries(incidencias) as [viewId, listaInc]}
<div
class="mb-4 last:mb-0 border border-red-200 rounded-lg p-4 cursor-pointer hover:shadow-md hover:bg-red-50 transition-all duration-200"
role="button"
tabindex="0"
title="Volver a configurar esta estancia"
onclick={() => handleViewClick(viewId)}
onkeydown={(e) => e.key === 'Enter' && handleViewClick(viewId)}
>
<h3 class="font-semibold text-red-800 mb-3 flex items-center justify-between">
<span>{listaInc[0].viewName}</span>
<span class="text-xs bg-red-100 px-2 py-1 rounded-full">
{listaInc.length} {listaInc.length === 1 ? 'opción' : 'opciones'}
</span>
<div class="bg-gray-500 text-white p-3">
<h3 class="text-sm font-semibold tracking-wider uppercase">
Costes de personalización
</h3>
<div class="space-y-2">
{#each listaInc as inc}
<div class="bg-white p-3 rounded border border-red-100">
<div class="font-medium text-sm text-gray-800 mb-1">
{inc.groupName}: <span class="text-red-700">{inc.optionName}</span>
</div>
<div class="text-xs text-gray-600">{inc.mensaje}</div>
</div>
{/each}
</div>
</div>
{/each}
<div class="p-4 space-y-3 flex-grow text-left">
<p>
En el resumen final de elecciones de cada vivienda se incluirá un concepto correspondiente a la <strong>elaboración de documentación técnica y gestión de la personalización</strong>, cuyo importe se determinará en función del grado de modificación solicitado por el cliente.
</p>
<p>Los importes aplicables serán los siguientes:</p>
<ul>
<li><strong>Grado 0:</strong> 0,00 €</li>
<li><strong>Grado 1:</strong> 500,00 €</li>
<li><strong>Grado 2:</strong> 1.000,00 €</li>
</ul>
<p>
El grado asignado dependerá del alcance y complejidad de los cambios realizados respecto al proyecto estándar.
</p>
</div>
</div>
</div>
{/if}
<!-- TOTALES -->
{#if totalGeneral > 0}
<div class="col-span-1 {hasIncidencias ? '' : 'lg:col-start-4'}">
<div class="bg-white rounded-xl border border-gray-300 shadow-sm overflow-hidden flex flex-col sticky top-4">
{#if totalGeneral > -1}
<div class="col-span-1 lg:col-start-4">
<div class="bg-white rounded-xl border border-gray-800 shadow-xl overflow-hidden flex flex-col sticky top-4 h-full">
<div class="bg-green-800 text-white p-3">
<div class="bg-gray-700 text-white p-3">
<h3 class="text-sm font-semibold tracking-wider uppercase">
Total Extras
Total Configuración
</h3>
</div>
{#if hasIncidencias}
<div class="p-2 bg-orange-200 w-full">
<a href="#incidencias" onclick="{(e) => scrollTo(e,'incidencias')}">
<span>Se han encontrado incidencias en su configuración. Consúltelas más abajo.</span>
</a>
</div>
{/if}
<div class="p-4 space-y-3 flex-grow ">
{#each appState.getAllViews() as view}
{@const total = getTotalView(view.id)}
{#if total > 0}
<div class="flex justify-between items-center text-gray-700">
<span class="text-sm font-medium">{view.name || view.id}</span>
<span class="text-sm font-semibold">{formatearPrecio(total)}</span>
<div class="flex justify-between items-center text-gray-700 border-gray-300 border-b pt-2">
<span class="text-m font-medium">{view.name || view.id}</span>
<span class="text-m font-semibold">{formatPrice(total)}</span>
</div>
{/if}
{/each}
@ -202,57 +147,70 @@
<div class="p-4 border-t border-gray-200 bg-amber-50">
<div class="flex justify-between items-center">
<span class="text-sm font-bold text-gray-800 uppercase tracking-wide">TOTAL</span>
<span class="text-lg font-bold text-green-700">{formatearPrecio(totalGeneral)}</span>
<span class="text-lg font-bold text-green-700">{formatPrice(totalGeneral)}</span>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
<!-- OPCIONES GENERALES -->
{#if showGeneralOptions}
<div class="p-4 sm:p-6">
<div class="bg-white rounded-xl border border-gray-300 shadow-sm p-6">
<h2 class="text-2xl font-bold text-gray-800 mb-6">
{dataConfig.texts.title_options_general || 'Opciones Generales'}
<!-- INCIDENCIAS -->
{#if hasIncidencias}
<section id="incidencias" class="col-span-1 md:col-span-4 mt-10">
<div class="rounded-xl border border-orange-600 shadow-sm overflow-hidden">
<div class="w-full flex items-center gap-2 justify-between bg-orange-400 p-2">
<h2 class="text-xl font-bold text-black p-4">
<span class="p-2 text-3xl">⚠️</span>
<span>Hay opciones seleccionadas que requieren consulta</span>
</h2>
</div>
<div class="p-6 text-left bg-white">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each Object.entries(dataConfig.options.general) as [key, group]}
{@const currentOptionId = appState.getCurrentOption('general', key)}
{@const optionValue = currentOptionId ? appState.getOptionValue(currentOptionId) : null}
<div class="flex flex-col p-4 border-2 border-gray-200 rounded-lg hover:border-green-500 transition-colors">
<span class="text-xs text-gray-500 uppercase tracking-wider mb-2 font-semibold">
{group.name || group.id}
</span>
<span class="text-base font-semibold text-gray-800 mb-3">
{optionValue?.name || currentOptionId || 'No seleccionado'}
{#each Object.entries(incidencias) as [viewId, listaInc]}
<div
class="mb-4 last:mb-0 border border-orange-200 rounded-lg p-4 cursor-pointer hover:shadow-md hover:bg-orange-50 transition-all duration-200"
role="button"
tabindex="0"
title="Volver a configurar esta estancia"
onclick={() => handleViewClick(viewId)}
onkeydown={(e) => e.key === 'Enter' && handleViewClick(viewId)}
>
<h3 class="font-semibold text-gray-800 mb-3 flex items-center justify-between">
<span>{listaInc[0].viewName}</span>
<span class="text-xs bg-orange-300 px-3 py-1 rounded-full">
{listaInc.length} {listaInc.length === 1 ? 'opción' : 'opciones'}
</span>
</h3>
{#if optionValue?.thumb}
<img
src={dataConfig.sources.imgValues + optionValue.thumb}
alt={optionValue.name}
class="w-full h-32 object-cover rounded-md shadow-sm"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = 'none'; }}
/>
{/if}
<div class="space-y-2">
{#each listaInc as inc}
<div class="p-2 rounded border border-gray-400">
<div class="font-medium text-sm text-gray-800 mb-1">
{inc.groupName}: <span class="text-gray-700">{inc.optionName}</span>
</div>
<div class="text-xm text-gray-600">{inc.mensaje}</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</section>
{/if}
</div>
</div>
<!-- MENSAJE LEGAL O AVISO -->
<div class="p-4 sm:p-6">
<div class="bg-blue-50 border border-blue-200 rounded-xl p-6 text-center">
<p class="text-sm text-gray-700 leading-relaxed">
<div class="bg-gray-50 border border-gray-200 rounded-xl p-6 text-center">
<p class="text-m text-gray-700 leading-relaxed">
ℹ️ Las imágenes son orientativas y pueden no corresponder exactamente con el resultado final.
Los acabados están sujetos a disponibilidad.
</p>

Loading…
Cancel
Save

Powered by TurnKey Linux.