feat: connect calendar-booking frontend to NestJS backend with real-time Supabase persistence
This commit is contained in:
parent
45287f1dcd
commit
afa98589fb
@ -53,6 +53,9 @@
|
|||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"browserTarget": "App-frontend:build:production"
|
"browserTarget": "App-frontend:build:production"
|
||||||
|
|||||||
7
frontend/proxy.conf.json
Normal file
7
frontend/proxy.conf.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:3000",
|
||||||
|
"secure": false,
|
||||||
|
"logLevel": "debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1081,4 +1081,106 @@
|
|||||||
.gc-popup-btn-primary:hover {
|
.gc-popup-btn-primary:hover {
|
||||||
background: #185abc;
|
background: #185abc;
|
||||||
box-shadow: 0 1px 3px rgba(60,64,67,0.3), 0 2px 6px 2px rgba(60,64,67,0.15);
|
box-shadow: 0 1px 3px rgba(60,64,67,0.3), 0 2px 6px 2px rgba(60,64,67,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-popup-btn-primary:disabled {
|
||||||
|
background: #a8c7fa;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading bar ── */
|
||||||
|
.gc-loading-bar {
|
||||||
|
height: 3px;
|
||||||
|
width: 100%;
|
||||||
|
background: #e8f0fe;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-loading-bar__inner {
|
||||||
|
height: 100%;
|
||||||
|
width: 40%;
|
||||||
|
background: #1a73e8;
|
||||||
|
border-radius: 2px;
|
||||||
|
animation: gc-loading-slide 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gc-loading-slide {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(350%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error banner ── */
|
||||||
|
.gc-error-banner {
|
||||||
|
background: #fce8e6;
|
||||||
|
color: #c5221f;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid #f5c6c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Date display in sidebar ── */
|
||||||
|
.gc-date-display {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-date-display__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #5f6368;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Activity dot (color circle instead of avatar) ── */
|
||||||
|
.gc-activity-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty reservas text ── */
|
||||||
|
.gc-empty-reservas {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa0a6;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Popup feedback messages ── */
|
||||||
|
.gc-popup-error {
|
||||||
|
background: #fce8e6;
|
||||||
|
color: #c5221f;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-popup-success {
|
||||||
|
background: #e6f4ea;
|
||||||
|
color: #1e8e3e;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
animation: gc-fade-in 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gc-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<div class="gc-container">
|
<div class="gc-container">
|
||||||
|
|
||||||
<!-- ═══════════════════════════════ HEADER (GOOGLE CALENDAR TOPBAR) ═══════════════════════════════ -->
|
<!-- ═══════════════════════════════ HEADER ═══════════════════════════════ -->
|
||||||
<header class="gc-header">
|
<header class="gc-header">
|
||||||
<div class="gc-header__left">
|
<div class="gc-header__left">
|
||||||
<button class="gc-header__menu-btn" title="Menú principal">
|
<button class="gc-header__menu-btn" title="Menú principal">
|
||||||
@ -12,71 +12,74 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="gc-header__logo">
|
<div class="gc-header__logo">
|
||||||
|
|
||||||
<span class="gc-header__logo-text">Agenda de Salas</span>
|
<span class="gc-header__logo-text">Agenda de Salas</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="gc-header__btn gc-header__btn--rounded" (click)="selectedSlot.set(null)"
|
<button class="gc-header__btn gc-header__btn--rounded" (click)="goToToday()" title="Volver al día de hoy">Hoy</button>
|
||||||
title="Volver al día de hoy">Hoy</button>
|
|
||||||
|
|
||||||
<div class="gc-header__nav">
|
<div class="gc-header__nav">
|
||||||
<button class="gc-header__nav-btn" title="Anterior">‹</button>
|
<button class="gc-header__nav-btn" (click)="prevDay()" title="Día anterior">‹</button>
|
||||||
<button class="gc-header__nav-btn" title="Siguiente">›</button>
|
<button class="gc-header__nav-btn" (click)="nextDay()" title="Día siguiente">›</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="gc-header__title">Junio de 2026</h2>
|
<h2 class="gc-header__title">{{ monthLabel() | titlecase }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gc-header__right">
|
<div class="gc-header__right">
|
||||||
<!-- Search Input aligned inside Header -->
|
|
||||||
<div class="gc-header__search-container">
|
<div class="gc-header__search-container">
|
||||||
<svg class="gc-header__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg class="gc-header__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
stroke-width="2">
|
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
</svg>
|
</svg>
|
||||||
<input type="text" placeholder="Buscar recursos..." (input)="onSearch($event)"
|
<input type="text" placeholder="Buscar recursos..." (input)="onSearch($event)" class="gc-header__search-input" />
|
||||||
class="gc-header__search-input" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="gc-header__icon-btn" title="Asistencia"><span class="material-icons">help_outline</span></button>
|
<button class="gc-header__icon-btn" title="Asistencia"><span class="material-icons">help_outline</span></button>
|
||||||
<button class="gc-header__icon-btn" title="Menú Configuración"><span
|
<button class="gc-header__icon-btn" title="Configuración"><span class="material-icons">settings</span></button>
|
||||||
class="material-icons">settings</span></button>
|
|
||||||
|
|
||||||
<div class="gc-header__view-select">
|
<div class="gc-header__view-select">
|
||||||
<button class="gc-header__view-trigger">
|
<button class="gc-header__view-trigger">
|
||||||
<span>Semana</span>
|
<span>Día</span>
|
||||||
<span class="gc-header__view-arrow">▼</span>
|
<span class="gc-header__view-arrow">▼</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="gc-header__icon-btn" title="Aplicaciones de Google"><span
|
<button class="gc-header__icon-btn" title="Apps"><span class="material-icons">apps</span></button>
|
||||||
class="material-icons">apps</span></button>
|
<div class="gc-header__user-avatar">JO</div>
|
||||||
<div class="gc-header__user-avatar">JM</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════ LOADING BAR ═══════════════════════════════ -->
|
||||||
|
@if (reservasService.isLoading()) {
|
||||||
|
<div class="gc-loading-bar">
|
||||||
|
<div class="gc-loading-bar__inner"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- ═══════════════════════════════ MAIN LAYOUT BODY ═══════════════════════════════ -->
|
<!-- ═══════════════════════════════ MAIN LAYOUT BODY ═══════════════════════════════ -->
|
||||||
<div class="gc-body">
|
<div class="gc-body">
|
||||||
|
|
||||||
<!-- ── SIDEBAR IZQUIERDO (256px) ── -->
|
<!-- ── SIDEBAR IZQUIERDO ── -->
|
||||||
<aside class="gc-left-sidebar">
|
<aside class="gc-left-sidebar">
|
||||||
|
|
||||||
<!-- Pill style '+ Crear' Button -->
|
|
||||||
<div class="gc-sidebar-create">
|
<div class="gc-sidebar-create">
|
||||||
<button class="gc-btn-create" (click)="navigateToNuevo()">
|
<button class="gc-btn-create" (click)="navigateToNuevo()">
|
||||||
<span class="gc-btn-create__text">Crear</span>
|
<span class="gc-btn-create__text">+ Reservar</span>
|
||||||
<span class="gc-btn-create__arrow">▼</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mini monthly calendar widget -->
|
<!-- Fecha activa -->
|
||||||
|
<div class="gc-date-display">
|
||||||
|
<span class="gc-date-display__label">{{ activeDateLabel() | titlecase }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mini calendar dinámico -->
|
||||||
<div class="gc-mini-calendar">
|
<div class="gc-mini-calendar">
|
||||||
<div class="gc-mini-calendar__header">
|
<div class="gc-mini-calendar__header">
|
||||||
<span class="gc-mini-calendar__title">Junio de 2026</span>
|
<span class="gc-mini-calendar__title">{{ monthLabel() | titlecase }}</span>
|
||||||
<div class="gc-mini-calendar__nav">
|
<div class="gc-mini-calendar__nav">
|
||||||
<button class="gc-mini-calendar__nav-btn">‹</button>
|
<button class="gc-mini-calendar__nav-btn" (click)="prevDay()">‹</button>
|
||||||
<button class="gc-mini-calendar__nav-btn">›</button>
|
<button class="gc-mini-calendar__nav-btn" (click)="nextDay()">›</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -85,75 +88,63 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gc-mini-calendar__days-grid">
|
<div class="gc-mini-calendar__days-grid">
|
||||||
@for (d of miniCalendarDays; track d) {
|
@for (d of miniCalendarDays(); track d.date.toISOString()) {
|
||||||
<span class="gc-mini-calendar__day" [ngClass]="{
|
<span class="gc-mini-calendar__day"
|
||||||
'gc-mini-calendar__day--inactive': !d.currentMonth,
|
[ngClass]="{
|
||||||
'gc-mini-calendar__day--active': d.active
|
'gc-mini-calendar__day--inactive': !d.currentMonth,
|
||||||
}">
|
'gc-mini-calendar__day--active': d.active
|
||||||
|
}"
|
||||||
|
(click)="selectMiniCalendarDay(d.date)"
|
||||||
|
style="cursor: pointer;">
|
||||||
{{ d.day }}
|
{{ d.day }}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Box inside Sidebar -->
|
<!-- Acordeón categorías -->
|
||||||
<div class="gc-sidebar-search">
|
|
||||||
<div class="gc-search-box">
|
|
||||||
<span class="material-icons gc-search-box__icon">people_outline</span>
|
|
||||||
<input type="text" placeholder="Buscar a gente" class="gc-search-box__input" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Accordion: Mis Calendarios / Categorías de Reservas -->
|
|
||||||
<div class="gc-accordion">
|
<div class="gc-accordion">
|
||||||
<div class="gc-accordion__header">
|
<div class="gc-accordion__header">
|
||||||
<span class="gc-accordion__title">Mis Espacios</span>
|
<span class="gc-accordion__title">Mis Espacios</span>
|
||||||
<span class="material-icons gc-accordion__chevron">keyboard_arrow_up</span>
|
<span class="material-icons gc-accordion__chevron">keyboard_arrow_up</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gc-accordion__content">
|
<div class="gc-accordion__content">
|
||||||
<label class="gc-checkbox-item">
|
<label class="gc-checkbox-item">
|
||||||
<input type="checkbox" [checked]="isCategorySelected('Sala Reuniones')"
|
<input type="checkbox" [checked]="isCategorySelected('Sala Reuniones')" (change)="toggleCategory('Sala Reuniones')" class="gc-checkbox-item__input gc-checkbox-item__input--blue" />
|
||||||
(change)="toggleCategory('Sala Reuniones')"
|
|
||||||
class="gc-checkbox-item__input gc-checkbox-item__input--blue" />
|
|
||||||
<span class="gc-checkbox-item__label">Sala Reuniones</span>
|
<span class="gc-checkbox-item__label">Sala Reuniones</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="gc-checkbox-item">
|
<label class="gc-checkbox-item">
|
||||||
<input type="checkbox" [checked]="isCategorySelected('Oficina Flexible')"
|
<input type="checkbox" [checked]="isCategorySelected('Oficina Flexible')" (change)="toggleCategory('Oficina Flexible')" class="gc-checkbox-item__input gc-checkbox-item__input--green" />
|
||||||
(change)="toggleCategory('Oficina Flexible')"
|
|
||||||
class="gc-checkbox-item__input gc-checkbox-item__input--green" />
|
|
||||||
<span class="gc-checkbox-item__label">Oficina Flexible</span>
|
<span class="gc-checkbox-item__label">Oficina Flexible</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="gc-checkbox-item">
|
<label class="gc-checkbox-item">
|
||||||
<input type="checkbox" [checked]="isCategorySelected('Zona Relax')" (change)="toggleCategory('Zona Relax')"
|
<input type="checkbox" [checked]="isCategorySelected('Zona Relax')" (change)="toggleCategory('Zona Relax')" class="gc-checkbox-item__input gc-checkbox-item__input--yellow" />
|
||||||
class="gc-checkbox-item__input gc-checkbox-item__input--yellow" />
|
|
||||||
<span class="gc-checkbox-item__label">Zona Relax</span>
|
<span class="gc-checkbox-item__label">Zona Relax</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="gc-checkbox-item">
|
<label class="gc-checkbox-item">
|
||||||
<input type="checkbox" [checked]="isCategorySelected('Evento')" (change)="toggleCategory('Evento')"
|
<input type="checkbox" [checked]="isCategorySelected('Evento')" (change)="toggleCategory('Evento')" class="gc-checkbox-item__input gc-checkbox-item__input--purple" />
|
||||||
class="gc-checkbox-item__input gc-checkbox-item__input--purple" />
|
|
||||||
<span class="gc-checkbox-item__label">Eventos</span>
|
<span class="gc-checkbox-item__label">Eventos</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Accordion: Otros Calendarios / Mis Reservas Activas -->
|
<!-- Reservas del día -->
|
||||||
<div class="gc-accordion gc-accordion--reservas">
|
<div class="gc-accordion gc-accordion--reservas">
|
||||||
<div class="gc-accordion__header">
|
<div class="gc-accordion__header">
|
||||||
<span class="gc-accordion__title">Actividad de Reservas</span>
|
<span class="gc-accordion__title">Reservas del día</span>
|
||||||
<span class="material-icons gc-accordion__chevron">keyboard_arrow_up</span>
|
<span class="material-icons gc-accordion__chevron">keyboard_arrow_up</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gc-accordion__content gc-accordion__content--scroll">
|
<div class="gc-accordion__content gc-accordion__content--scroll">
|
||||||
@for (act of recentActivities(); track act.title) {
|
@if (reservasService.reservas().length === 0 && !reservasService.isLoading()) {
|
||||||
|
<p class="gc-empty-reservas">Sin reservas para este día.</p>
|
||||||
|
}
|
||||||
|
@for (r of reservasService.reservas(); track r.id) {
|
||||||
<div class="gc-activity-item">
|
<div class="gc-activity-item">
|
||||||
<img [src]="act.avatar" alt="user" class="gc-activity-item__avatar" />
|
<div class="gc-activity-dot" [style.background]="r.resourceName.includes('Sala') ? '#4285f4' : r.resourceName.includes('Auditorio') ? '#8b5cf6' : '#16a34a'"></div>
|
||||||
<div class="gc-activity-item__body">
|
<div class="gc-activity-item__body">
|
||||||
<span class="gc-activity-item__title"><strong>{{ act.title }}</strong></span>
|
<span class="gc-activity-item__title"><strong>{{ r.resourceName }}</strong></span>
|
||||||
<span class="gc-activity-item__desc">{{ act.detail }}</span>
|
<span class="gc-activity-item__desc">{{ r.startTime }} – {{ r.endTime }}</span>
|
||||||
<span class="gc-activity-item__time">{{ act.time }}</span>
|
<span class="gc-activity-item__time">{{ r.author }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -162,17 +153,22 @@
|
|||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- ── CENTRAL CALENDAR VIEW (GRID WEEK VIEW STYLE) ── -->
|
<!-- ── CENTRAL CALENDAR VIEW ── -->
|
||||||
<main class="gc-main-calendar">
|
<main class="gc-main-calendar">
|
||||||
|
|
||||||
<!-- Columns header (equivalent to days header) -->
|
<!-- Error global -->
|
||||||
|
@if (reservasService.error()) {
|
||||||
|
<div class="gc-error-banner">
|
||||||
|
<span class="material-icons">error_outline</span>
|
||||||
|
{{ reservasService.error() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Columnas de recursos (header) -->
|
||||||
<div class="gc-grid-header">
|
<div class="gc-grid-header">
|
||||||
<!-- Corner empty label for alignment with time column -->
|
|
||||||
<div class="gc-grid-header__corner">
|
<div class="gc-grid-header__corner">
|
||||||
<span class="gc-corner-gmt">GMT-04</span>
|
<span class="gc-corner-gmt">GMT-04</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Headers columns representing resources -->
|
|
||||||
<div class="gc-grid-header__columns">
|
<div class="gc-grid-header__columns">
|
||||||
@for (res of filteredResources(); track res.id; let idx = $index) {
|
@for (res of filteredResources(); track res.id; let idx = $index) {
|
||||||
<div class="gc-col-header" [class.gc-col-header--active]="idx === 0">
|
<div class="gc-col-header" [class.gc-col-header--active]="idx === 0">
|
||||||
@ -186,11 +182,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scrollable time cells grid -->
|
<!-- Grid de slots -->
|
||||||
<div class="gc-grid-scroll-area">
|
<div class="gc-grid-scroll-area">
|
||||||
<div class="gc-grid-body">
|
<div class="gc-grid-body">
|
||||||
|
|
||||||
<!-- Column of hours scale (left side) -->
|
<!-- Columna de horas -->
|
||||||
<div class="gc-hours-column">
|
<div class="gc-hours-column">
|
||||||
@for (time of timeSlots; track time) {
|
@for (time of timeSlots; track time) {
|
||||||
<div class="gc-hour-cell">
|
<div class="gc-hour-cell">
|
||||||
@ -199,16 +195,13 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Columns of Grid scheduler -->
|
<!-- Columnas por recurso -->
|
||||||
<div class="gc-columns-grid"
|
<div class="gc-columns-grid" [style.grid-template-columns]="'repeat(' + filteredResources().length + ', 1fr)'">
|
||||||
[style.grid-template-columns]="'repeat(' + filteredResources().length + ', 1fr)'">
|
|
||||||
|
|
||||||
<!-- Red Horizontal Line: Current Time Indicator -->
|
|
||||||
<div class="gc-current-time-line" style="top: 52%;" title="Hora actual aproximada">
|
<div class="gc-current-time-line" style="top: 52%;" title="Hora actual aproximada">
|
||||||
<div class="gc-current-time-line__circle"></div>
|
<div class="gc-current-time-line__circle"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Generate columns per resource -->
|
|
||||||
@for (res of filteredResources(); track res.id; let colIdx = $index) {
|
@for (res of filteredResources(); track res.id; let colIdx = $index) {
|
||||||
<div class="gc-resource-column">
|
<div class="gc-resource-column">
|
||||||
|
|
||||||
@ -216,34 +209,17 @@
|
|||||||
<div class="gc-grid-slot-cell">
|
<div class="gc-grid-slot-cell">
|
||||||
|
|
||||||
@if (isSlotOccupied(res.name, time)) {
|
@if (isSlotOccupied(res.name, time)) {
|
||||||
<!-- Event Busy Block (pastel styles styled exactly as Google Calendar events) -->
|
<!-- Slot ocupado -->
|
||||||
<div class="gc-event gc-event--busy gc-event--color-{{ colIdx % 4 }}">
|
<div class="gc-event gc-event--busy gc-event--color-{{ colIdx % 4 }}" [title]="getReservaForSlot(res.name, time)?.author || 'Reservado'">
|
||||||
<div class="gc-event__title">Reservado / Ocupado</div>
|
<div class="gc-event__title">{{ getReservaForSlot(res.name, time)?.author || 'Reservado' }}</div>
|
||||||
<div class="gc-event__time">{{ time }} - {{ getEndTime(time) }}</div>
|
<div class="gc-event__time">{{ time }} – {{ getEndTime(time) }}</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
|
<!-- Slot disponible -->
|
||||||
<!-- Flex space placeholder mock showing avatars on Oficina Flex -->
|
<div class="gc-slot-click-zone" (click)="onSlotClick(res, time)" title="Clic para reservar"></div>
|
||||||
@if (res.name === 'OFICINA FLEX A1' && time === '12:00') {
|
|
||||||
<div class="gc-event-flex-avatars" (click)="onSlotClick(res, time)">
|
|
||||||
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=40&q=80"
|
|
||||||
alt="emp" class="gc-mini-avatar" />
|
|
||||||
<img src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=40&q=80"
|
|
||||||
alt="emp" class="gc-mini-avatar" />
|
|
||||||
</div>
|
|
||||||
} @else if (res.name === 'OFICINA FLEX A1' && time === '13:30') {
|
|
||||||
<div class="gc-event-flex-avatars" (click)="onSlotClick(res, time)">
|
|
||||||
<img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=40&q=80"
|
|
||||||
alt="emp" class="gc-mini-avatar" />
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<!-- Empty Available Cell click to add -->
|
|
||||||
<div class="gc-slot-click-zone" (click)="onSlotClick(res, time)" title="Haga clic para reservar"></div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
<!-- Popup de confirmación -->
|
||||||
|
|
||||||
<!-- Google Calendar style interactive Event creation popup -->
|
|
||||||
@if (selectedSlot()?.resource?.id === res.id && selectedSlot()?.time === time) {
|
@if (selectedSlot()?.resource?.id === res.id && selectedSlot()?.time === time) {
|
||||||
<div class="gc-popup-card" (click)="$event.stopPropagation()">
|
<div class="gc-popup-card" (click)="$event.stopPropagation()">
|
||||||
<div class="gc-popup-card__header">
|
<div class="gc-popup-card__header">
|
||||||
@ -253,28 +229,45 @@
|
|||||||
|
|
||||||
<div class="gc-popup-card__body">
|
<div class="gc-popup-card__body">
|
||||||
<h4 class="gc-popup-card__title">{{ res.name }}</h4>
|
<h4 class="gc-popup-card__title">{{ res.name }}</h4>
|
||||||
|
|
||||||
<div class="gc-popup-card__row">
|
<div class="gc-popup-card__row">
|
||||||
<span class="material-icons gc-popup-card__icon">schedule</span>
|
<span class="material-icons gc-popup-card__icon">schedule</span>
|
||||||
<div class="gc-popup-card__details">
|
<div class="gc-popup-card__details">
|
||||||
<span>Martes, 16 de Junio</span>
|
<span>{{ activeDateLabel() | titlecase }}</span>
|
||||||
<span class="gc-popup-card__time">{{ time }} – {{ getEndTime(time) }}</span>
|
<span class="gc-popup-card__time">{{ time }} – {{ getEndTime(time) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gc-popup-card__row">
|
<div class="gc-popup-card__row">
|
||||||
<span class="material-icons gc-popup-card__icon">room</span>
|
<span class="material-icons gc-popup-card__icon">room</span>
|
||||||
<span>Capacidad máxima: {{ res.capacity }} personas</span>
|
<span>Capacidad: {{ res.capacity }} personas</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gc-popup-card__row">
|
<div class="gc-popup-card__row">
|
||||||
<span class="material-icons gc-popup-card__icon">info_outline</span>
|
<span class="material-icons gc-popup-card__icon">info_outline</span>
|
||||||
<span class="gc-popup-card__tag">{{ res.type }}</span>
|
<span class="gc-popup-card__tag">{{ res.type }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Feedback -->
|
||||||
|
@if (confirmError()) {
|
||||||
|
<div class="gc-popup-error">
|
||||||
|
<span class="material-icons" style="font-size:14px">error</span>
|
||||||
|
{{ confirmError() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (confirmSuccess()) {
|
||||||
|
<div class="gc-popup-success">
|
||||||
|
<span class="material-icons" style="font-size:14px">check_circle</span>
|
||||||
|
¡Reserva guardada en Supabase!
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gc-popup-card__actions">
|
<div class="gc-popup-card__actions">
|
||||||
<button (click)="navigateToNuevo()" class="gc-popup-btn-secondary">Más opciones</button>
|
<button (click)="navigateToNuevo()" class="gc-popup-btn-secondary">Más opciones</button>
|
||||||
<button (click)="confirmReserva()" class="gc-popup-btn-primary">Guardar</button>
|
<button (click)="confirmReserva()" class="gc-popup-btn-primary" [disabled]="confirmLoading()">
|
||||||
|
@if (confirmLoading()) { Guardando... } @else { Guardar }
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -298,29 +291,17 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- ── SIDEBAR DERECHO (48px) ── -->
|
<!-- ── SIDEBAR DERECHO ── -->
|
||||||
<aside class="gc-right-sidebar">
|
<aside class="gc-right-sidebar">
|
||||||
<div class="gc-right-sidebar__icons">
|
<div class="gc-right-sidebar__icons">
|
||||||
<button class="gc-right-sidebar__btn gc-right-sidebar__btn--keep" title="Keep">
|
<button class="gc-right-sidebar__btn gc-right-sidebar__btn--tasks" title="Mis Reservas">
|
||||||
<img src="https://www.gstatic.com/companion/icon_assets/keep_2020q4_v3.png" alt="Keep" />
|
<span class="material-icons" style="font-size:20px;color:#4285f4">event_available</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="gc-right-sidebar__btn gc-right-sidebar__btn--tasks" title="Tasks">
|
|
||||||
<img src="https://www.gstatic.com/companion/icon_assets/tasks_2021_2v.png" alt="Tasks" />
|
|
||||||
</button>
|
|
||||||
<button class="gc-right-sidebar__btn gc-right-sidebar__btn--contacts" title="Contactos">
|
|
||||||
<img src="https://www.gstatic.com/companion/icon_assets/contacts_2022_3.png" alt="Contacts" />
|
|
||||||
</button>
|
|
||||||
<button class="gc-right-sidebar__btn gc-right-sidebar__btn--maps" title="Maps">
|
|
||||||
<img src="https://www.gstatic.com/companion/icon_assets/maps_v2.png" alt="Maps" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="gc-right-sidebar__divider"></div>
|
<div class="gc-right-sidebar__divider"></div>
|
||||||
|
<button class="gc-right-sidebar__btn gc-right-sidebar__btn--plus" title="Nueva reserva" (click)="navigateToNuevo()">
|
||||||
<button class="gc-right-sidebar__btn gc-right-sidebar__btn--plus" title="Obtener complementos">
|
|
||||||
<span class="material-icons">add</span>
|
<span class="material-icons">add</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="gc-right-sidebar__btn gc-right-sidebar__btn--chevron" title="Ocultar panel lateral">
|
<button class="gc-right-sidebar__btn gc-right-sidebar__btn--chevron" title="Ocultar panel lateral">
|
||||||
<span class="material-icons">chevron_right</span>
|
<span class="material-icons">chevron_right</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Component, OnInit, signal, computed, inject } from '@angular/core';
|
import { Component, OnInit, signal, computed, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router, RouterModule } from '@angular/router';
|
import { Router, RouterModule } from '@angular/router';
|
||||||
|
import { ReservasService } from '../services/reservas.service';
|
||||||
|
|
||||||
export interface Resource {
|
export interface Resource {
|
||||||
id: string;
|
id: string;
|
||||||
@ -20,137 +21,129 @@ export interface Resource {
|
|||||||
})
|
})
|
||||||
export class CalendarBookingComponent implements OnInit {
|
export class CalendarBookingComponent implements OnInit {
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
reservasService = inject(ReservasService);
|
||||||
|
|
||||||
// Horarios de la captura
|
// Horarios laborales (en hora exacta para coincidir con el backend)
|
||||||
timeSlots = ['08:00', '09:30', '10:00', '11:30', '12:00', '13:30', '14:00', '15:00'];
|
timeSlots = ['09:00', '10:00', '11:00', '12:00', '14:00', '15:00', '16:00', '17:00'];
|
||||||
|
|
||||||
// Base de datos de recursos por categoría
|
// Recursos disponibles — alineados con el seed
|
||||||
resources = signal<Resource[]>([
|
resources = signal<Resource[]>([
|
||||||
{ id: '1', name: 'Atom 1', type: 'Sala Reuniones', icon: '🌙', desc: 'Sala Reuniones resutenias or Sala Luna', capacity: 8 },
|
{ id: 'sala-A', name: 'Sala de Reuniones A', type: 'Sala Reuniones', icon: '🌙', desc: 'Sala equipada para reuniones ejecutivas', capacity: 8 },
|
||||||
{ id: '2', name: 'Atom 2', type: 'Sala Reuniones', icon: '☀️', desc: 'Estables:nación de nuevos luxes', capacity: 12 },
|
{ id: 'sala-B', name: 'Sala de Reuniones B', type: 'Sala Reuniones', icon: '☀️', desc: 'Sala estándar para equipos de trabajo', capacity: 12 },
|
||||||
{ id: '3', name: 'Atom 3', type: 'Oficina Flexible', icon: '🏢', desc: 'Resultalioes sala tia r Oficina flexible', capacity: 1 },
|
{ id: 'cowork-1', name: 'Espacio Coworking 1', type: 'Oficina Flexible', icon: '🏢', desc: 'Puesto de trabajo abierto y colaborativo', capacity: 1 },
|
||||||
{ id: '4', name: 'Atom 4', type: 'Oficina Flexible', icon: '🏢', desc: 'Oficina de espacios resutentu-u planetia', capacity: 1 },
|
{ id: 'cowork-2', name: 'Espacio Coworking 2', type: 'Oficina Flexible', icon: '🏢', desc: 'Puesto de trabajo con vista panorámica', capacity: 1 },
|
||||||
{ id: '5', name: 'Atom 5', type: 'Zona Relax', icon: '☕', desc: 'Espacio de café y socialización', capacity: 20 },
|
{ id: 'auditorio', name: 'Auditorio Principal', type: 'Evento', icon: '🎤', desc: 'Auditorio para presentaciones corporativas', capacity: 100 },
|
||||||
{ id: '6', name: 'Atom 6', type: 'Zona Relax', icon: '🎮', desc: 'Mesa de ping pong y consolas', capacity: 10 },
|
|
||||||
{ id: '7', name: 'Atom 7', type: 'Evento', icon: '🎤', desc: 'Auditorio principal para presentaciones', capacity: 100 },
|
|
||||||
{ id: '8', name: 'Atom 8', type: 'Evento', icon: '🌿', desc: 'Espacio abierto para eventos corporativos', capacity: 50 },
|
|
||||||
{ id: '9', name: 'Sala Fusiòn', type: 'Sala Reuniones', icon: '🌿', desc: 'Espacio abierto para eventos corporativos', capacity: 15 }
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Signals para gestionar estado interactivo
|
// ── Estado de la fecha activa ──
|
||||||
|
activeDate = signal<Date>(this.getNextWeekday(new Date()));
|
||||||
|
|
||||||
|
// ── Filtros ──
|
||||||
selectedCategories = signal<string[]>(['Sala Reuniones', 'Oficina Flexible', 'Zona Relax', 'Evento']);
|
selectedCategories = signal<string[]>(['Sala Reuniones', 'Oficina Flexible', 'Zona Relax', 'Evento']);
|
||||||
searchQuery = signal<string>('');
|
searchQuery = signal<string>('');
|
||||||
|
|
||||||
// Slot seleccionado para el popup dinámico
|
// ── UI ──
|
||||||
selectedSlot = signal<{ resource: Resource, time: string } | null>(null);
|
selectedSlot = signal<{ resource: Resource; time: string } | null>(null);
|
||||||
|
confirmLoading = signal<boolean>(false);
|
||||||
|
confirmError = signal<string | null>(null);
|
||||||
|
confirmSuccess = signal<boolean>(false);
|
||||||
|
|
||||||
// Ocupación en tiempo real (inicializada con la captura)
|
// ── Computed ──
|
||||||
reservations = signal<any[]>([
|
|
||||||
{ resourceName: 'SALA LUNA', time: '08:00', author: 'Sistema', date: 'Hace 10h' },
|
|
||||||
{ resourceName: 'SALA LUNA', time: '09:30', author: 'Sistema', date: 'Hace 10h' },
|
|
||||||
{ resourceName: 'SALA LUNA', time: '13:30', author: 'Sistema', date: 'Hace 10h' },
|
|
||||||
{ resourceName: 'SALA LUNA', time: '14:00', author: 'Sistema', date: 'Hace 10h' },
|
|
||||||
{ resourceName: 'SALA SOL', time: '08:00', author: 'Sistema', date: 'Hace 10h' },
|
|
||||||
{ resourceName: 'SALA SOL', time: '09:30', author: 'Sistema', date: 'Hace 10h' }
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Notificaciones estáticas de David Chen
|
|
||||||
staticNotifications = [
|
|
||||||
{ title: 'David Chen', detail: 'compartió Resultados del Trimestre', time: 'Hace 15m', type: 'notif', avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=120&q=80' },
|
|
||||||
{ title: 'David Chen', detail: 'compartió Resultados del Trimestre', time: 'Hace 15m', type: 'notif', avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=120&q=80' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Mini calendar data for June 2026
|
|
||||||
miniCalendarDays = [
|
|
||||||
{ day: 31, currentMonth: false },
|
|
||||||
{ day: 1, currentMonth: true },
|
|
||||||
{ day: 2, currentMonth: true },
|
|
||||||
{ day: 3, currentMonth: true },
|
|
||||||
{ day: 4, currentMonth: true },
|
|
||||||
{ day: 5, currentMonth: true },
|
|
||||||
{ day: 6, currentMonth: true },
|
|
||||||
{ day: 7, currentMonth: true },
|
|
||||||
{ day: 8, currentMonth: true },
|
|
||||||
{ day: 9, currentMonth: true },
|
|
||||||
{ day: 10, currentMonth: true },
|
|
||||||
{ day: 11, currentMonth: true },
|
|
||||||
{ day: 12, currentMonth: true },
|
|
||||||
{ day: 13, currentMonth: true },
|
|
||||||
{ day: 14, currentMonth: true },
|
|
||||||
{ day: 15, currentMonth: true },
|
|
||||||
{ day: 16, currentMonth: true, active: true },
|
|
||||||
{ day: 17, currentMonth: true },
|
|
||||||
{ day: 18, currentMonth: true },
|
|
||||||
{ day: 19, currentMonth: true },
|
|
||||||
{ day: 20, currentMonth: true },
|
|
||||||
{ day: 21, currentMonth: true },
|
|
||||||
{ day: 22, currentMonth: true },
|
|
||||||
{ day: 23, currentMonth: true },
|
|
||||||
{ day: 24, currentMonth: true },
|
|
||||||
{ day: 25, currentMonth: true },
|
|
||||||
{ day: 26, currentMonth: true },
|
|
||||||
{ day: 27, currentMonth: true },
|
|
||||||
{ day: 28, currentMonth: true },
|
|
||||||
{ day: 29, currentMonth: true },
|
|
||||||
{ day: 30, currentMonth: true },
|
|
||||||
{ day: 1, currentMonth: false },
|
|
||||||
{ day: 2, currentMonth: false },
|
|
||||||
{ day: 3, currentMonth: false },
|
|
||||||
{ day: 4, currentMonth: false },
|
|
||||||
{ day: 5, currentMonth: false },
|
|
||||||
{ day: 6, currentMonth: false },
|
|
||||||
{ day: 7, currentMonth: false },
|
|
||||||
{ day: 8, currentMonth: false },
|
|
||||||
{ day: 9, currentMonth: false },
|
|
||||||
{ day: 10, currentMonth: false },
|
|
||||||
{ day: 11, currentMonth: false }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Computed signals
|
|
||||||
filteredResources = computed(() => {
|
filteredResources = computed(() => {
|
||||||
const selectedCats = this.selectedCategories();
|
const cats = this.selectedCategories();
|
||||||
const query = this.searchQuery().toLowerCase().trim();
|
const q = this.searchQuery().toLowerCase().trim();
|
||||||
return this.resources().filter(r =>
|
return this.resources().filter(
|
||||||
selectedCats.includes(r.type) &&
|
(r) =>
|
||||||
(!query || r.name.toLowerCase().includes(query) || r.desc.toLowerCase().includes(query))
|
cats.includes(r.type) &&
|
||||||
|
(!q || r.name.toLowerCase().includes(q) || r.desc.toLowerCase().includes(q))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
recentActivities = computed(() => {
|
activeDateStr = computed(() => this.toDateStr(this.activeDate()));
|
||||||
// Convertir reservas a actividades
|
|
||||||
const resList = this.reservations().map(res => {
|
activeDateLabel = computed(() => {
|
||||||
let avatar = 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=120&q=80';
|
const d = this.activeDate();
|
||||||
if (res.resourceName.includes('LUNA')) {
|
return d.toLocaleDateString('es-ES', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
avatar = 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=120&q=80';
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
title: res.resourceName,
|
|
||||||
detail: `Reservado ${res.time}`,
|
|
||||||
time: res.date,
|
|
||||||
type: 'reserva',
|
|
||||||
avatar: avatar
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return [...resList, ...this.staticNotifications];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit(): void {
|
monthLabel = computed(() => {
|
||||||
// Inicializar el popup en Sala Luna de las 10:00 para emular la captura al cargar
|
const d = this.activeDate();
|
||||||
const luna = this.resources().find(r => r.name === 'SALA LUNA');
|
return d.toLocaleDateString('es-ES', { month: 'long', year: 'numeric' });
|
||||||
if (luna) {
|
});
|
||||||
this.selectedSlot.set({ resource: luna, time: '11:30' });
|
|
||||||
|
// Mini calendar — genera los días del mes de la fecha activa
|
||||||
|
miniCalendarDays = computed(() => {
|
||||||
|
const d = this.activeDate();
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = d.getMonth();
|
||||||
|
const firstDay = new Date(year, month, 1).getDay();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const prevDays = new Date(year, month, 0).getDate();
|
||||||
|
const days: { day: number; currentMonth: boolean; active?: boolean; date: Date }[] = [];
|
||||||
|
|
||||||
|
// días del mes anterior para completar la primera semana
|
||||||
|
for (let i = firstDay - 1; i >= 0; i--) {
|
||||||
|
const day = prevDays - i;
|
||||||
|
days.push({ day, currentMonth: false, date: new Date(year, month - 1, day) });
|
||||||
}
|
}
|
||||||
|
// días del mes actual
|
||||||
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
|
const date = new Date(year, month, i);
|
||||||
|
days.push({ day: i, currentMonth: true, active: this.toDateStr(date) === this.activeDateStr(), date });
|
||||||
|
}
|
||||||
|
// completar hasta 42 celdas
|
||||||
|
const remaining = 42 - days.length;
|
||||||
|
for (let i = 1; i <= remaining; i++) {
|
||||||
|
days.push({ day: i, currentMonth: false, date: new Date(year, month + 1, i) });
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Lifecycle ──
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadForDate(this.activeDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Navegación de fechas ──
|
||||||
|
prevDay(): void {
|
||||||
|
const d = new Date(this.activeDate());
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
this.activeDate.set(d);
|
||||||
|
this.selectedSlot.set(null);
|
||||||
|
this.loadForDate(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextDay(): void {
|
||||||
|
const d = new Date(this.activeDate());
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
this.activeDate.set(d);
|
||||||
|
this.selectedSlot.set(null);
|
||||||
|
this.loadForDate(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
goToToday(): void {
|
||||||
|
const today = this.getNextWeekday(new Date());
|
||||||
|
this.activeDate.set(today);
|
||||||
|
this.selectedSlot.set(null);
|
||||||
|
this.loadForDate(today);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectMiniCalendarDay(date: Date): void {
|
||||||
|
this.activeDate.set(date);
|
||||||
|
this.selectedSlot.set(null);
|
||||||
|
this.loadForDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadForDate(date: Date): void {
|
||||||
|
this.reservasService.loadReservas(this.toDateStr(date)).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filtros ──
|
||||||
toggleCategory(cat: string): void {
|
toggleCategory(cat: string): void {
|
||||||
this.selectedCategories.update(prev => {
|
this.selectedCategories.update((prev) =>
|
||||||
if (prev.includes(cat)) {
|
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
||||||
return prev.filter(c => c !== cat);
|
);
|
||||||
} else {
|
this.selectedSlot.set(null);
|
||||||
return [...prev, cat];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.selectedSlot.set(null); // Reset popup al cambiar filtros
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isCategorySelected(cat: string): boolean {
|
isCategorySelected(cat: string): boolean {
|
||||||
@ -158,55 +151,101 @@ export class CalendarBookingComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSearch(event: Event): void {
|
onSearch(event: Event): void {
|
||||||
const value = (event.target as HTMLInputElement).value;
|
this.searchQuery.set((event.target as HTMLInputElement).value);
|
||||||
this.searchQuery.set(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Slots ──
|
||||||
isSlotOccupied(resourceName: string, time: string): boolean {
|
isSlotOccupied(resourceName: string, time: string): boolean {
|
||||||
return this.reservations().some(res => res.resourceName === resourceName && res.time === time);
|
return this.reservasService.isOccupied(resourceName, this.activeDateStr(), time);
|
||||||
|
}
|
||||||
|
|
||||||
|
getReservaForSlot(resourceName: string, time: string) {
|
||||||
|
return this.reservasService.reservas().find(
|
||||||
|
(r) => r.resourceName === resourceName && r.date === this.activeDateStr() && r.startTime === time
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSlotClick(resource: Resource, time: string): void {
|
onSlotClick(resource: Resource, time: string): void {
|
||||||
if (this.isSlotOccupied(resource.name, time)) {
|
if (this.isSlotOccupied(resource.name, time)) return;
|
||||||
return;
|
const today = new Date();
|
||||||
}
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const selected = new Date(this.activeDate());
|
||||||
|
selected.setHours(0, 0, 0, 0);
|
||||||
|
if (selected < today) return; // no permitir reservar en el pasado
|
||||||
|
this.confirmError.set(null);
|
||||||
|
this.confirmSuccess.set(false);
|
||||||
this.selectedSlot.set({ resource, time });
|
this.selectedSlot.set({ resource, time });
|
||||||
}
|
}
|
||||||
|
|
||||||
getEndTime(startTime: string): string {
|
// ── Guardar reserva en Supabase ──
|
||||||
const [h, m] = startTime.split(':').map(Number);
|
|
||||||
let fh = h + 1;
|
|
||||||
let fm = m + 30;
|
|
||||||
if (fm >= 60) {
|
|
||||||
fh += 1;
|
|
||||||
fm -= 60;
|
|
||||||
}
|
|
||||||
return `${String(fh).padStart(2, '0')}:${String(fm).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmReserva(): void {
|
confirmReserva(): void {
|
||||||
const slot = this.selectedSlot();
|
const slot = this.selectedSlot();
|
||||||
if (slot) {
|
if (!slot) return;
|
||||||
const newRes = {
|
|
||||||
resourceName: slot.resource.name,
|
this.confirmLoading.set(true);
|
||||||
time: slot.time,
|
this.confirmError.set(null);
|
||||||
author: 'Jess Miller',
|
this.confirmSuccess.set(false);
|
||||||
date: 'Hace un momento'
|
|
||||||
};
|
const endTime = this.getEndTime(slot.time);
|
||||||
this.reservations.update(prev => [newRes, ...prev]);
|
|
||||||
this.selectedSlot.set(null);
|
this.reservasService.create({
|
||||||
}
|
resourceId: slot.resource.id,
|
||||||
|
resourceName: slot.resource.name,
|
||||||
|
date: this.activeDateStr(),
|
||||||
|
startTime: slot.time,
|
||||||
|
endTime,
|
||||||
|
author: 'Jessica Orozco',
|
||||||
|
}).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.confirmLoading.set(false);
|
||||||
|
this.confirmSuccess.set(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.selectedSlot.set(null);
|
||||||
|
this.confirmSuccess.set(false);
|
||||||
|
}, 1200);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.confirmLoading.set(false);
|
||||||
|
if (err.status === 409) {
|
||||||
|
this.confirmError.set('Este espacio ya está reservado para ese horario.');
|
||||||
|
} else {
|
||||||
|
this.confirmError.set('Error al conectar con el servidor.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
closePopup(): void {
|
closePopup(): void {
|
||||||
this.selectedSlot.set(null);
|
this.selectedSlot.set(null);
|
||||||
|
this.confirmError.set(null);
|
||||||
|
this.confirmSuccess.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilidades ──
|
||||||
|
getEndTime(startTime: string): string {
|
||||||
|
const [h, m] = startTime.split(':').map(Number);
|
||||||
|
const end = new Date(0, 0, 0, h + 1, m);
|
||||||
|
return `${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDateStr(date: Date): string {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Devuelve el día dado, o el próximo lunes si es fin de semana */
|
||||||
|
private getNextWeekday(date: Date): Date {
|
||||||
|
const d = new Date(date);
|
||||||
|
const day = d.getDay();
|
||||||
|
if (day === 0) d.setDate(d.getDate() + 1); // domingo → lunes
|
||||||
|
if (day === 6) d.setDate(d.getDate() + 2); // sábado → lunes
|
||||||
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateToNuevo(): void {
|
navigateToNuevo(): void {
|
||||||
const slot = this.selectedSlot();
|
const slot = this.selectedSlot();
|
||||||
this.router.navigate(['/reservas/nuevo'], {
|
this.router.navigate(['/reservas/nuevo'], {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
fecha: new Date().toISOString().split('T')[0],
|
fecha: this.activeDateStr(),
|
||||||
tipo: slot ? (slot.resource.type === 'Sala Reuniones' ? 'room' : 'desk') : 'room',
|
tipo: slot ? (slot.resource.type === 'Sala Reuniones' ? 'room' : 'desk') : 'room',
|
||||||
hora: slot ? slot.time : '10:00'
|
hora: slot ? slot.time : '10:00'
|
||||||
}
|
}
|
||||||
|
|||||||
110
frontend/src/app/features/reservas/services/reservas.service.ts
Normal file
110
frontend/src/app/features/reservas/services/reservas.service.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable, tap, catchError, throwError } from 'rxjs';
|
||||||
|
|
||||||
|
export interface Reserva {
|
||||||
|
id: string;
|
||||||
|
resourceId: string;
|
||||||
|
resourceName: string;
|
||||||
|
date: string; // 'YYYY-MM-DD'
|
||||||
|
startTime: string; // 'HH:mm'
|
||||||
|
endTime: string; // 'HH:mm'
|
||||||
|
author: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateReservaDto {
|
||||||
|
resourceId: string;
|
||||||
|
resourceName: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
author: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ReservasService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private readonly apiUrl = '/api/reservas';
|
||||||
|
|
||||||
|
// Estado reactivo con Signals (Angular 18)
|
||||||
|
reservas = signal<Reserva[]>([]);
|
||||||
|
isLoading = signal<boolean>(false);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carga todas las reservas o filtra por fecha (YYYY-MM-DD).
|
||||||
|
* Actualiza el signal `reservas` con el resultado.
|
||||||
|
*/
|
||||||
|
loadReservas(date?: string): Observable<Reserva[]> {
|
||||||
|
this.isLoading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (date) params = params.set('date', date);
|
||||||
|
|
||||||
|
return this.http.get<Reserva[]>(this.apiUrl, { params }).pipe(
|
||||||
|
tap((data) => {
|
||||||
|
this.reservas.set(data);
|
||||||
|
this.isLoading.set(false);
|
||||||
|
}),
|
||||||
|
catchError((err) => {
|
||||||
|
this.isLoading.set(false);
|
||||||
|
this.error.set('No se pudo conectar con el servidor.');
|
||||||
|
return throwError(() => err);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una nueva reserva en Supabase vía el backend.
|
||||||
|
* El backend rechaza duplicados (mismo recurso + fecha + hora) con 409.
|
||||||
|
*/
|
||||||
|
create(dto: CreateReservaDto): Observable<Reserva> {
|
||||||
|
this.isLoading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
|
||||||
|
return this.http.post<Reserva>(this.apiUrl, dto).pipe(
|
||||||
|
tap((nueva) => {
|
||||||
|
// Optimistic update: agrega al inicio del signal
|
||||||
|
this.reservas.update((prev) => [nueva, ...prev]);
|
||||||
|
this.isLoading.set(false);
|
||||||
|
}),
|
||||||
|
catchError((err) => {
|
||||||
|
this.isLoading.set(false);
|
||||||
|
if (err.status === 409) {
|
||||||
|
this.error.set('Este espacio ya está reservado para ese horario.');
|
||||||
|
} else {
|
||||||
|
this.error.set('Error al guardar la reserva.');
|
||||||
|
}
|
||||||
|
return throwError(() => err);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina una reserva por ID.
|
||||||
|
*/
|
||||||
|
delete(id: string): Observable<{ message: string }> {
|
||||||
|
return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`).pipe(
|
||||||
|
tap(() => {
|
||||||
|
this.reservas.update((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
}),
|
||||||
|
catchError((err) => throwError(() => err))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si un slot está ocupado consultando el estado local del signal.
|
||||||
|
*/
|
||||||
|
isOccupied(resourceName: string, date: string, startTime: string): boolean {
|
||||||
|
return this.reservas().some(
|
||||||
|
(r) =>
|
||||||
|
r.resourceName === resourceName &&
|
||||||
|
r.date === date &&
|
||||||
|
r.startTime === startTime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user