Add i18n integration and translations for improved localization
- Extend English (`en.json`) and Spanish (`es-ES.json`) locale files with additional keys for buildings, bookings, reports, and user management. - Update Svelte components and pages to use `svelte-i18n` for dynamic translations across the app. - Refactor labels, titles, and error messages for multilingual support. - Improve date formatting based on locale preferences.main
parent
e03335be67
commit
5fe893c7ce
|
|
@ -4,13 +4,17 @@
|
|||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add Docker deployment support for API and Web services - Add Dockerfiles for API and Web apps with multi-stage builds. - Configure `docker-compose.yml` to manage services (Postgres, API, Web, pgAdmin). - Add `.dockerignore` for optimized builds. - Include `DOCKER_SETUP.md` for deployment steps and guidance.">
|
||||
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add rotation support for spaces in floorplan editor - Extend `Space` model and Prisma schema to include `rotation` property. - Update API services and DTOs to handle rotation data. - Add UI controls in `FloorplanEditor.svelte` for rotating spaces. - Refactor space rendering logic in both editor and viewer to support rotation.">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/dto/update-space.dto.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/dto/update-space.dto.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/BookingModal.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/BookingModal.svelte" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/admin/FloorplanEditor.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/admin/FloorplanEditor.svelte" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/en.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/en.json" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/es-ES.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/es-ES.json" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/admin/reports/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/admin/reports/+page.svelte" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/admin/users/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/admin/users/+page.svelte" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/building/[id]/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/building/[id]/+page.svelte" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/my-bookings/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/my-bookings/+page.svelte" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
|
|
@ -132,7 +136,7 @@
|
|||
<updated>1774643312599</updated>
|
||||
<workItem from="1774643313611" duration="74000" />
|
||||
<workItem from="1774643422240" duration="5278000" />
|
||||
<workItem from="1774706361502" duration="10075000" />
|
||||
<workItem from="1774706361502" duration="10700000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services - Set up SvelteKit for the web interface with Tailwind CSS. - Build the API using NestJS with Prisma ORM for database interaction. - Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. - Create shared data models for consistent typing across services. - Establish basic routing and layouts for web and API services.">
|
||||
<option name="closed" value="true" />
|
||||
|
|
@ -286,7 +290,15 @@
|
|||
<option name="project" value="LOCAL" />
|
||||
<updated>1774715983233</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="20" />
|
||||
<task id="LOCAL-00020" summary="Add rotation support for spaces in floorplan editor - Extend `Space` model and Prisma schema to include `rotation` property. - Update API services and DTOs to handle rotation data. - Add UI controls in `FloorplanEditor.svelte` for rotating spaces. - Refactor space rendering logic in both editor and viewer to support rotation.">
|
||||
<option name="closed" value="true" />
|
||||
<created>1774716452045</created>
|
||||
<option name="number" value="00020" />
|
||||
<option name="presentableId" value="LOCAL-00020" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1774716452045</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="21" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
|
|
@ -314,6 +326,7 @@
|
|||
<MESSAGE value="Add i18n support with English and Spanish translations - Introduce `svelte-i18n` for internationalization. - Add English (`en.json`) and Spanish (`es-ES.json`) locale files. - Implement `setupI18n` function for initializing and managing locales. - Update project dependencies to include `svelte-i18n`." />
|
||||
<MESSAGE value="Update prompts to include progress indicator for documentation readability" />
|
||||
<MESSAGE value="Add Docker deployment support for API and Web services - Add Dockerfiles for API and Web apps with multi-stage builds. - Configure `docker-compose.yml` to manage services (Postgres, API, Web, pgAdmin). - Add `.dockerignore` for optimized builds. - Include `DOCKER_SETUP.md` for deployment steps and guidance." />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Add Docker deployment support for API and Web services - Add Dockerfiles for API and Web apps with multi-stage builds. - Configure `docker-compose.yml` to manage services (Postgres, API, Web, pgAdmin). - Add `.dockerignore` for optimized builds. - Include `DOCKER_SETUP.md` for deployment steps and guidance." />
|
||||
<MESSAGE value="Add rotation support for spaces in floorplan editor - Extend `Space` model and Prisma schema to include `rotation` property. - Update API services and DTOs to handle rotation data. - Add UI controls in `FloorplanEditor.svelte` for rotating spaces. - Refactor space rendering logic in both editor and viewer to support rotation." />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Add rotation support for spaces in floorplan editor - Extend `Space` model and Prisma schema to include `rotation` property. - Update API services and DTOs to handle rotation data. - Add UI controls in `FloorplanEditor.svelte` for rotating spaces. - Refactor space rendering logic in both editor and viewer to support rotation." />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -17,7 +17,8 @@
|
|||
let loadingReservations = false;
|
||||
|
||||
$: dateLocale = $locale === 'es-ES' ? es : enUS;
|
||||
$: formattedDate = format(selectedDate, 'PPP', { locale: dateLocale });
|
||||
$: dateFormat = $locale === 'es-ES' ? 'dd/MM/yyyy' : 'PPP';
|
||||
$: formattedDate = format(selectedDate, dateFormat, { locale: dateLocale });
|
||||
|
||||
$: {
|
||||
if (selectedDate) {
|
||||
|
|
@ -82,7 +83,7 @@
|
|||
end.setHours(eh, em, 0, 0);
|
||||
|
||||
if (end <= start) {
|
||||
error = "End time must be after start time";
|
||||
error = $t('booking.errorTime');
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
|
@ -100,7 +101,7 @@
|
|||
console.log('Booking created successfully');
|
||||
dispatch('close');
|
||||
} catch (e: any) {
|
||||
error = e.message || "Failed to create booking";
|
||||
error = e.message || $t('booking.errorCreating');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
|
@ -121,7 +122,7 @@
|
|||
});
|
||||
dispatch('close');
|
||||
} catch (e: any) {
|
||||
error = e.message || "Failed to delete booking";
|
||||
error = e.message || $t('booking.errorDeleting');
|
||||
confirmDelete = false;
|
||||
} finally {
|
||||
deleting = false;
|
||||
|
|
@ -252,19 +253,25 @@
|
|||
<Calendar size={16} />
|
||||
{$t('booking.bookingDate')}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl font-medium text-gray-700 flex justify-between items-center shadow-sm">
|
||||
<span>{format(new Date(bookingDate + 'T00:00:00'), dateFormat, { locale: dateLocale })}</span>
|
||||
<Calendar size={18} class="text-indigo-600" />
|
||||
</div>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={bookingDate}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
class="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none font-medium"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if space.type === 'MEETING_ROOM'}
|
||||
<div class="space-y-4">
|
||||
<label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2">
|
||||
<Clock size={16} />
|
||||
{$t('booking.availabilityFor', { values: { date: bookingDate } })}
|
||||
{$t('booking.availabilityFor', { values: { date: format(new Date(bookingDate), dateFormat, { locale: dateLocale }) } })}
|
||||
</label>
|
||||
|
||||
{#if loadingReservations}
|
||||
|
|
@ -327,7 +334,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div class="bg-indigo-50 border border-indigo-100 rounded-xl p-4">
|
||||
<p class="text-indigo-800 text-sm font-medium">Desks are booked for the full day.</p>
|
||||
<p class="text-indigo-800 text-sm font-medium">{$t('booking.deskNote')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -338,9 +345,9 @@
|
|||
>
|
||||
{#if loading}
|
||||
<Loader2 class="animate-spin" size={20} />
|
||||
Booking...
|
||||
{$t('booking.booking')}
|
||||
{:else}
|
||||
Confirm Booking
|
||||
{$t('booking.confirm')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import BookingModal from './BookingModal.svelte';
|
||||
import Avatar from './Avatar.svelte';
|
||||
import { resolveAssetUrl } from '$lib/utils/url';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let floorId: string;
|
||||
export let spaces: any[] = [];
|
||||
|
|
@ -151,7 +152,7 @@
|
|||
on:click={() => handleSpaceClick(space)}
|
||||
class="absolute p-2 border-2 rounded-lg shadow-md transition-all overflow-hidden flex items-center justify-center {space.type !== 'AMENITY' ? 'hover:scale-105 cursor-pointer' : 'cursor-default opacity-80'} {status.isOccupied ? 'bg-yellow-50 border-yellow-500' : 'bg-green-50 border-green-500'}"
|
||||
style="left: 0; top: 0; width: {(space.width / 100) * containerWidth}px; height: {(space.height / 100) * containerHeight}px; transform: translate({(space.x / 100) * containerWidth}px, {(space.y / 100) * containerHeight}px)"
|
||||
title="{space.name} ({status.isOccupied ? 'Booked' : 'Available'})"
|
||||
title="{space.name} ({status.isOccupied ? $t('booking.currentlyBooked') : $t('booking.available')})"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1 shrink-0 justify-center"
|
||||
|
|
|
|||
|
|
@ -27,9 +27,31 @@
|
|||
"title": "Dashboard",
|
||||
"welcome": "Welcome back",
|
||||
"selectBuilding": "Select a building to see available floors",
|
||||
"noBuildings": "No buildings found"
|
||||
"noBuildings": "No buildings found",
|
||||
"noBuildingsDesc": "Your organization doesn't have any buildings assigned yet.",
|
||||
"addBuilding": "Add Building",
|
||||
"viewBuilding": "View Building",
|
||||
"floors": "{count, plural, =1 {1 Floor} other {# Floors}}",
|
||||
"loading": "Loading buildings..."
|
||||
},
|
||||
"building": {
|
||||
"back": "Back to Dashboard",
|
||||
"loading": "Loading building details...",
|
||||
"selectDate": "Select Date",
|
||||
"selectFloor": "Select Floor",
|
||||
"floor": "Floor {number}",
|
||||
"editFloorplan": "Edit Floorplan",
|
||||
"switching": "Switching floor...",
|
||||
"interactiveView": "Interactive View - Click a space to see details or book.",
|
||||
"noFloorplan": "No floorplan image uploaded. Showing default grid.",
|
||||
"selectToSee": "Select a floor to see the layout",
|
||||
"notFound": "Building not found",
|
||||
"notFoundDesc": "The building you're looking for doesn't exist or you don't have access.",
|
||||
"return": "Return to Dashboard",
|
||||
"selectDate": "Select date"
|
||||
},
|
||||
"booking": {
|
||||
"available": "Available",
|
||||
"book": "Book",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm Booking",
|
||||
|
|
@ -49,6 +71,12 @@
|
|||
"desk": "Desk",
|
||||
"amenity": "Amenity",
|
||||
"currentlyBooked": "Currently Booked",
|
||||
"editBooking": "Edit Booking",
|
||||
"deskNote": "Desks are booked for the full day.",
|
||||
"booking": "Booking...",
|
||||
"errorCreating": "Failed to create booking",
|
||||
"errorDeleting": "Failed to delete booking",
|
||||
"errorTime": "End time must be after start time",
|
||||
"spaceType": {
|
||||
"DESK": "Desk",
|
||||
"MEETING_ROOM": "Meeting Room",
|
||||
|
|
@ -64,6 +92,67 @@
|
|||
"cancelConfirm": "Are you sure you want to cancel this booking?",
|
||||
"cancelFailed": "Failed to cancel booking",
|
||||
"updateFailed": "Failed to update booking",
|
||||
"loadFailed": "Failed to load bookings"
|
||||
"loadFailed": "Failed to load bookings",
|
||||
"editBooking": "Edit Booking",
|
||||
"space": "Space",
|
||||
"floor": "Planta {number}",
|
||||
"deskNote": "Note: Desk bookings are full-day. Times will be automatically adjusted to encompass the entire day.",
|
||||
"saving": "Saving...",
|
||||
"editBookingTitle": "Edit Booking",
|
||||
"saveChanges": "Save Changes"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Admin Reporting Dashboard",
|
||||
"downloadData": "Download XLSX Data",
|
||||
"overallUtilization": "Overall Utilization",
|
||||
"totalSpaces": "Total Spaces",
|
||||
"occupiedNow": "{count} currently occupied",
|
||||
"totalEmployees": "Total Employees",
|
||||
"acrossRoles": "Across all roles",
|
||||
"utilizationByBuilding": "Utilization by Building",
|
||||
"utilizationBySpaceType": "Utilization by Space Type",
|
||||
"employeeRoleDistribution": "Employee Role Distribution",
|
||||
"topBookers": "Top Bookers (Most Reservations)",
|
||||
"noBookingsFound": "No bookings found yet.",
|
||||
"bookingsCount": "{count, plural, =1 {1 booking} other {# bookings}}",
|
||||
"exportFailed": "Export failed",
|
||||
"downloadError": "Error downloading report: {error}"
|
||||
},
|
||||
"users": {
|
||||
"title": "User Management",
|
||||
"invite": {
|
||||
"role": "Role",
|
||||
"createLink": "Create Invite Link",
|
||||
"generated": "Invitation Link Generated",
|
||||
"description": "Anyone with this link can register as a <strong>{role}</strong> in your organization.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"done": "Done",
|
||||
"error": "Error generating invite: {error}"
|
||||
},
|
||||
"table": {
|
||||
"user": "User",
|
||||
"role": "Role",
|
||||
"joined": "Joined",
|
||||
"actions": "Actions",
|
||||
"id": "ID"
|
||||
},
|
||||
"roles": {
|
||||
"EMPLOYEE": "Employee",
|
||||
"SUPERVISOR": "Supervisor",
|
||||
"ADMIN": "Admin"
|
||||
},
|
||||
"password": {
|
||||
"change": "Change Password",
|
||||
"title": "Change Password",
|
||||
"description": "Set a new password for <strong>{email}</strong>",
|
||||
"new": "New Password",
|
||||
"placeholder": "At least 6 characters",
|
||||
"save": "Save Password",
|
||||
"success": "Password updated successfully",
|
||||
"error": "Error updating password: {error}"
|
||||
},
|
||||
"updateRoleError": "Error updating role: {error}",
|
||||
"loading": "Loading users..."
|
||||
}
|
||||
}
|
||||
|
|
@ -27,9 +27,31 @@
|
|||
"title": "Panel de control",
|
||||
"welcome": "Bienvenido/a de nuevo",
|
||||
"selectBuilding": "Seleccione un edificio para ver las plantas disponibles",
|
||||
"noBuildings": "No se han encontrado edificios"
|
||||
"noBuildings": "No se han encontrado edificios",
|
||||
"noBuildingsDesc": "Su organización aún no tiene edificios asignados.",
|
||||
"addBuilding": "Añadir edificio",
|
||||
"viewBuilding": "Ver edificio",
|
||||
"floors": "{count, plural, =1 {1 Planta} other {# Plantas}}",
|
||||
"loading": "Cargando edificios..."
|
||||
},
|
||||
"building": {
|
||||
"back": "Volver al panel",
|
||||
"loading": "Cargando detalles del edificio...",
|
||||
"selectDate": "Seleccionar fecha",
|
||||
"selectFloor": "Seleccionar planta",
|
||||
"floor": "Planta {number}",
|
||||
"editFloorplan": "Editar plano",
|
||||
"switching": "Cambiando de planta...",
|
||||
"interactiveView": "Vista interactiva - Haga clic en un espacio para ver detalles o reservar.",
|
||||
"noFloorplan": "No se ha subido ninguna imagen del plano. Mostrando cuadrícula predeterminada.",
|
||||
"selectToSee": "Seleccione una planta para ver la distribución",
|
||||
"notFound": "Edificio no encontrado",
|
||||
"notFoundDesc": "El edificio que busca no existe o no tiene acceso.",
|
||||
"return": "Volver al panel",
|
||||
"selectDate": "Seleccionar fecha"
|
||||
},
|
||||
"booking": {
|
||||
"available": "Disponible",
|
||||
"book": "Reservar",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar reserva",
|
||||
|
|
@ -49,6 +71,12 @@
|
|||
"desk": "Puesto",
|
||||
"amenity": "Servicio",
|
||||
"currentlyBooked": "Reservado actualmente",
|
||||
"editBooking": "Editar reserva",
|
||||
"deskNote": "Los puestos se reservan para el día completo.",
|
||||
"booking": "Reservando...",
|
||||
"errorCreating": "Error al crear la reserva",
|
||||
"errorDeleting": "Error al eliminar la reserva",
|
||||
"errorTime": "La hora de finalización debe ser posterior a la de inicio",
|
||||
"spaceType": {
|
||||
"DESK": "Puesto",
|
||||
"MEETING_ROOM": "Sala de reuniones",
|
||||
|
|
@ -64,6 +92,67 @@
|
|||
"cancelConfirm": "¿Está seguro/a de que desea cancelar esta reserva?",
|
||||
"cancelFailed": "Error al cancelar la reserva",
|
||||
"updateFailed": "Error al actualizar la reserva",
|
||||
"loadFailed": "Error al cargar las reservas"
|
||||
"loadFailed": "Error al cargar las reservas",
|
||||
"editBooking": "Editar reserva",
|
||||
"space": "Espacio",
|
||||
"floor": "Planta {number}",
|
||||
"deskNote": "Nota: Las reservas de puestos son para todo el día. Las horas se ajustarán automáticamente para abarcar el día completo.",
|
||||
"saving": "Guardando...",
|
||||
"editBookingTitle": "Editar reserva",
|
||||
"saveChanges": "Guardar cambios"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Panel de Control de Informes",
|
||||
"downloadData": "Descargar Datos XLSX",
|
||||
"overallUtilization": "Ocupación General",
|
||||
"totalSpaces": "Total de Espacios",
|
||||
"occupiedNow": "{count} ocupados actualmente",
|
||||
"totalEmployees": "Total de Empleados",
|
||||
"acrossRoles": "En todos los roles",
|
||||
"utilizationByBuilding": "Ocupación por Edificio",
|
||||
"utilizationBySpaceType": "Ocupación por Tipo de Espacio",
|
||||
"employeeRoleDistribution": "Distribución de Empleados por Rol",
|
||||
"topBookers": "Usuarios con más reservas",
|
||||
"noBookingsFound": "Aún no se han encontrado reservas.",
|
||||
"bookingsCount": "{count, plural, =1 {1 reserva} other {# reservas}}",
|
||||
"exportFailed": "Error al exportar",
|
||||
"downloadError": "Error al descargar el informe: {error}"
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestión de Usuarios",
|
||||
"invite": {
|
||||
"role": "Rol",
|
||||
"createLink": "Crear enlace de invitación",
|
||||
"generated": "Enlace de invitación generado",
|
||||
"description": "Cualquier persona con este enlace puede registrarse como <strong>{role}</strong> en su organización.",
|
||||
"copy": "Copiar",
|
||||
"copied": "¡Copiado!",
|
||||
"done": "Hecho",
|
||||
"error": "Error al generar la invitación: {error}"
|
||||
},
|
||||
"table": {
|
||||
"user": "Usuario",
|
||||
"role": "Rol",
|
||||
"joined": "Unido el",
|
||||
"actions": "Acciones",
|
||||
"id": "ID"
|
||||
},
|
||||
"roles": {
|
||||
"EMPLOYEE": "Empleado",
|
||||
"SUPERVISOR": "Supervisor",
|
||||
"ADMIN": "Administrador"
|
||||
},
|
||||
"password": {
|
||||
"change": "Cambiar contraseña",
|
||||
"title": "Cambiar contraseña",
|
||||
"description": "Establecer una nueva contraseña para <strong>{email}</strong>",
|
||||
"new": "Nueva contraseña",
|
||||
"placeholder": "Al menos 6 caracteres",
|
||||
"save": "Guardar contraseña",
|
||||
"success": "Contraseña actualizada con éxito",
|
||||
"error": "Error al actualizar la contraseña: {error}"
|
||||
},
|
||||
"updateRoleError": "Error al actualizar el rol: {error}",
|
||||
"loading": "Cargando usuarios..."
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { token } from '$lib/stores/auth';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
|
||||
|
|
@ -32,7 +33,7 @@
|
|||
'Authorization': `Bearer ${$token}`
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error('Export failed');
|
||||
if (!res.ok) throw new Error($t('reports.exportFailed'));
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
|
@ -43,7 +44,7 @@
|
|||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
alert('Error downloading report: ' + e.message);
|
||||
alert($t('reports.downloadError', { values: { error: e.message } }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +53,7 @@
|
|||
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold text-gray-800">Admin Reporting Dashboard</h1>
|
||||
<h1 class="text-2xl font-bold text-gray-800">{$t('reports.title')}</h1>
|
||||
<button
|
||||
on:click={downloadReport}
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center space-x-2"
|
||||
|
|
@ -60,7 +61,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Download XLSX Data</span>
|
||||
<span>{$t('reports.downloadData')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -80,7 +81,7 @@
|
|||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
||||
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wider">Overall Utilization</h3>
|
||||
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wider">{$t('reports.overallUtilization')}</h3>
|
||||
<p class="mt-2 text-3xl font-bold text-indigo-600">{utilizationStats.summary.overallUtilization.toFixed(1)}%</p>
|
||||
<div class="mt-4 w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-indigo-600 h-2 rounded-full" style="width: {utilizationStats.summary.overallUtilization}%"></div>
|
||||
|
|
@ -88,15 +89,15 @@
|
|||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
||||
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wider">Total Spaces</h3>
|
||||
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wider">{$t('reports.totalSpaces')}</h3>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900">{utilizationStats.summary.totalSpaces}</p>
|
||||
<p class="text-sm text-gray-500 mt-1">{utilizationStats.summary.occupiedSpaces} currently occupied</p>
|
||||
<p class="text-sm text-gray-500 mt-1">{$t('reports.occupiedNow', { values: { count: utilizationStats.summary.occupiedSpaces } })}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
||||
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wider">Total Employees</h3>
|
||||
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wider">{$t('reports.totalEmployees')}</h3>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900">{employeeStats.totalEmployees}</p>
|
||||
<p class="text-sm text-gray-500 mt-1">Across all roles</p>
|
||||
<p class="text-sm text-gray-500 mt-1">{$t('reports.acrossRoles')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -104,7 +105,7 @@
|
|||
<!-- Building Utilization -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<h3 class="font-bold text-gray-800">Utilization by Building</h3>
|
||||
<h3 class="font-bold text-gray-800">{$t('reports.utilizationByBuilding')}</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
|
|
@ -126,14 +127,14 @@
|
|||
<!-- Space Type Distribution -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<h3 class="font-bold text-gray-800">Utilization by Space Type</h3>
|
||||
<h3 class="font-bold text-gray-800">{$t('reports.utilizationBySpaceType')}</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
{#each utilizationStats.typeStats as typeStat}
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="font-medium text-gray-700">{typeStat.type.replace('_', ' ')}</span>
|
||||
<span class="font-medium text-gray-700">{$t(`booking.spaceType.${typeStat.type}`)}</span>
|
||||
<span class="text-gray-500">{typeStat.occupied} / {typeStat.total} ({typeStat.utilization.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 rounded-full h-2">
|
||||
|
|
@ -148,7 +149,7 @@
|
|||
<!-- Role Distribution -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<h3 class="font-bold text-gray-800">Employee Role Distribution</h3>
|
||||
<h3 class="font-bold text-gray-800">{$t('reports.employeeRoleDistribution')}</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
|
|
@ -165,19 +166,19 @@
|
|||
<!-- Top Bookers -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<h3 class="font-bold text-gray-800">Top Bookers (Most Reservations)</h3>
|
||||
<h3 class="font-bold text-gray-800">{$t('reports.topBookers')}</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
{#each employeeStats.topBookers as booker}
|
||||
<div class="px-6 py-3 flex justify-between items-center hover:bg-gray-50 transition-colors">
|
||||
<span class="text-sm font-medium text-gray-700">{booker.email}</span>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
|
||||
{booker.reservationsCount} bookings
|
||||
{$t('reports.bookingsCount', { values: { count: booker.reservationsCount } })}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if employeeStats.topBookers.length === 0}
|
||||
<div class="px-6 py-8 text-center text-gray-500 italic">No bookings found yet.</div>
|
||||
<div class="px-6 py-8 text-center text-gray-500 italic">{$t('reports.noBookingsFound')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { token } from '$lib/stores/auth';
|
||||
import { t, locale } from 'svelte-i18n';
|
||||
import { format } from 'date-fns';
|
||||
import { enUS, es } from 'date-fns/locale';
|
||||
|
||||
$: dateLocale = $locale === 'es-ES' ? es : enUS;
|
||||
$: dateFormat = $locale === 'es-ES' ? 'dd/MM/yyyy' : 'PP';
|
||||
|
||||
let users = [];
|
||||
let loading = true;
|
||||
|
|
@ -33,7 +39,7 @@
|
|||
});
|
||||
await fetchUsers();
|
||||
} catch (e) {
|
||||
alert('Error updating role: ' + e.message);
|
||||
alert($t('users.updateRoleError', { values: { error: e.message } }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,9 +56,9 @@
|
|||
body: JSON.stringify({ password: newPassword })
|
||||
});
|
||||
showPasswordModal = false;
|
||||
alert('Password updated successfully');
|
||||
alert($t('users.password.success'));
|
||||
} catch (e) {
|
||||
alert('Error updating password: ' + e.message);
|
||||
alert($t('users.password.error', { values: { error: e.message } }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +72,7 @@
|
|||
inviteToken = `${baseUrl}/register?token=${res.token}`;
|
||||
showInviteModal = true;
|
||||
} catch (e) {
|
||||
alert('Error generating invite: ' + e.message);
|
||||
alert($t('users.invite.error', { values: { error: e.message } }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,25 +87,26 @@
|
|||
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold text-gray-800">User Management</h1>
|
||||
<h1 class="text-2xl font-bold text-gray-800">{$t('users.title')}</h1>
|
||||
<div class="flex space-x-2">
|
||||
<select bind:value={inviteRole} class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="EMPLOYEE">Employee</option>
|
||||
<option value="SUPERVISOR">Supervisor</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="EMPLOYEE">{$t('users.roles.EMPLOYEE')}</option>
|
||||
<option value="SUPERVISOR">{$t('users.roles.SUPERVISOR')}</option>
|
||||
<option value="ADMIN">{$t('users.roles.ADMIN')}</option>
|
||||
</select>
|
||||
<button
|
||||
on:click={generateInvite}
|
||||
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Create Invite Link
|
||||
{$t('users.invite.createLink')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<div class="flex flex-col items-center justify-center py-12 space-y-4">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
<p class="text-gray-500">{$t('users.loading')}</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="bg-red-50 text-red-700 p-4 rounded-lg border border-red-200">
|
||||
|
|
@ -110,10 +117,10 @@
|
|||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t('users.table.user')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t('users.table.role')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t('users.table.joined')}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{$t('users.table.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
|
|
@ -121,7 +128,7 @@
|
|||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">{user.email}</div>
|
||||
<div class="text-xs text-gray-500">ID: {user.id.substring(0, 8)}...</div>
|
||||
<div class="text-xs text-gray-500">{$t('users.table.id')}: {user.id.substring(0, 8)}...</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<select
|
||||
|
|
@ -129,20 +136,20 @@
|
|||
on:change={(e) => updateUserRole(user.id, e.target.value)}
|
||||
class="text-sm rounded-md border-gray-200 focus:ring-indigo-500 focus:border-indigo-500 py-1"
|
||||
>
|
||||
<option value="EMPLOYEE">Employee</option>
|
||||
<option value="SUPERVISOR">Supervisor</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="EMPLOYEE">{$t('users.roles.EMPLOYEE')}</option>
|
||||
<option value="SUPERVISOR">{$t('users.roles.SUPERVISOR')}</option>
|
||||
<option value="ADMIN">{$t('users.roles.ADMIN')}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
{format(new Date(user.createdAt), dateFormat, { locale: dateLocale })}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
on:click={() => openPasswordModal(user)}
|
||||
class="text-indigo-600 hover:text-indigo-900 bg-indigo-50 px-3 py-1 rounded-md"
|
||||
>
|
||||
Change Password
|
||||
{$t('users.password.change')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -157,8 +164,8 @@
|
|||
{#if showInviteModal}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div class="bg-white rounded-xl p-6 max-w-lg w-full shadow-xl">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">Invitation Link Generated</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">Anyone with this link can register as a <strong>{inviteRole}</strong> in your organization.</p>
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">{$t('users.invite.generated')}</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">{@html $t('users.invite.description', { values: { role: $t(`users.roles.${inviteRole}`) } })}</p>
|
||||
|
||||
<div class="flex items-center space-x-2 bg-gray-50 p-3 rounded-lg border border-gray-200 mb-6">
|
||||
<input type="text" readonly value={inviteToken} class="bg-transparent border-none focus:ring-0 text-sm flex-1 truncate" />
|
||||
|
|
@ -167,9 +174,9 @@
|
|||
class="bg-white border border-gray-300 px-3 py-1.5 rounded-md text-sm font-medium hover:bg-gray-50 flex items-center space-x-1"
|
||||
>
|
||||
{#if copied}
|
||||
<span class="text-green-600">Copied!</span>
|
||||
<span class="text-green-600">{$t('users.invite.copied')}</span>
|
||||
{:else}
|
||||
<span>Copy</span>
|
||||
<span>{$t('users.invite.copy')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -179,7 +186,7 @@
|
|||
on:click={() => showInviteModal = false}
|
||||
class="bg-gray-900 text-white px-6 py-2 rounded-lg font-medium"
|
||||
>
|
||||
Done
|
||||
{$t('users.invite.done')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -190,17 +197,17 @@
|
|||
{#if showPasswordModal}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div class="bg-white rounded-xl p-6 max-w-md w-full shadow-xl">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">Change Password</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">Set a new password for <strong>{selectedUser?.email}</strong></p>
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">{$t('users.password.title')}</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">{@html $t('users.password.description', { values: { email: selectedUser?.email } })}</p>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-700 mb-1">New Password</label>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-700 mb-1">{$t('users.password.new')}</label>
|
||||
<input
|
||||
type="password"
|
||||
id="new-password"
|
||||
bind:value={newPassword}
|
||||
placeholder="At least 6 characters"
|
||||
placeholder={$t('users.password.placeholder')}
|
||||
class="w-full rounded-lg border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -211,14 +218,14 @@
|
|||
on:click={() => showPasswordModal = false}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
on:click={updatePassword}
|
||||
disabled={newPassword.length < 6}
|
||||
class="bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white px-6 py-2 rounded-lg font-medium"
|
||||
>
|
||||
Save Password
|
||||
{$t('users.password.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
class="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Add Building
|
||||
{$t('dashboard.addBuilding')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
<button
|
||||
on:click={() => openEditModal(building)}
|
||||
class="p-2 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
|
||||
title="Edit Building"
|
||||
title={$t('common.edit')}
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
|
|
@ -97,14 +97,14 @@
|
|||
<p class="text-gray-500 text-sm mb-4 line-clamp-2">{building.description}</p>
|
||||
{/if}
|
||||
<p class="text-gray-400 text-xs mb-6 uppercase tracking-wider font-semibold">
|
||||
{building.floors?.length || 0} Floors
|
||||
{$t('dashboard.floors', { values: { count: building.floors?.length || 0 } })}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/dashboard/building/{building.id}"
|
||||
class="inline-flex items-center justify-center w-full px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors gap-2"
|
||||
>
|
||||
View Building
|
||||
{$t('dashboard.viewBuilding')}
|
||||
<ArrowRight size={18} />
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -114,8 +114,8 @@
|
|||
{#if buildings.length === 0}
|
||||
<div class="col-span-full bg-gray-50 border-2 border-dashed border-gray-300 rounded-xl p-12 text-center">
|
||||
<Building2 class="mx-auto text-gray-400 mb-4" size={48} />
|
||||
<h3 class="text-lg font-medium text-gray-900">No buildings found</h3>
|
||||
<p class="text-gray-500 mt-1">Your organization doesn't have any buildings assigned yet.</p>
|
||||
<h3 class="text-lg font-medium text-gray-900">{$t('dashboard.noBuildings')}</h3>
|
||||
<p class="text-gray-500 mt-1">{$t('dashboard.noBuildingsDesc')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
import { apiFetch } from "$lib/api/client";
|
||||
import FloorplanViewer from '$lib/components/FloorplanViewer.svelte';
|
||||
import { Building2, Layers, Map as MapIcon, ChevronLeft, Settings, Calendar } from "lucide-svelte";
|
||||
import { t, locale } from "svelte-i18n";
|
||||
import { format } from "date-fns";
|
||||
import { es, enUS } from "date-fns/locale";
|
||||
|
||||
let building: any = null;
|
||||
let currentFloor: any = null;
|
||||
|
|
@ -25,6 +28,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
$: dateLocale = $locale?.startsWith('es') ? es : enUS;
|
||||
$: dateFormat = $locale === 'es-ES' ? 'dd/MM/yyyy' : 'EEEE, MMMM d';
|
||||
|
||||
onMount(async () => {
|
||||
const buildingId = $page.params.id;
|
||||
if (!$token) {
|
||||
|
|
@ -69,14 +75,14 @@
|
|||
<div class="mb-6">
|
||||
<a href="/dashboard" class="inline-flex items-center text-indigo-600 hover:text-indigo-700 font-medium gap-1">
|
||||
<ChevronLeft size={20} />
|
||||
Back to Dashboard
|
||||
{$t('building.back')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex flex-col items-center justify-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mb-4"></div>
|
||||
<p class="text-gray-500">Loading building details...</p>
|
||||
<p class="text-gray-500">{$t('building.loading')}</p>
|
||||
</div>
|
||||
{:else if building}
|
||||
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center mb-8 gap-4">
|
||||
|
|
@ -89,32 +95,36 @@
|
|||
<div class="flex items-center gap-4 mt-1 text-gray-500">
|
||||
<span class="flex items-center gap-1.5 text-sm">
|
||||
<Layers size={16} />
|
||||
{building.floors?.length || 0} Floors
|
||||
{$t('dashboard.floors', { values: { count: building.floors?.length || 0 } })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 w-full lg:w-auto">
|
||||
<div class="relative flex-1 lg:flex-none min-w-[180px]">
|
||||
<label for="date-select" class="absolute -top-2 left-3 px-1 bg-gray-50 text-[10px] font-bold text-indigo-600 uppercase tracking-wider">Select Date</label>
|
||||
<div class="relative flex-1 lg:flex-none min-w-[200px]">
|
||||
<span class="absolute -top-2 left-3 px-1 bg-gray-50 text-[10px] font-bold text-indigo-600 uppercase tracking-wider">{$t('building.selectDate')}</span>
|
||||
<div class="w-full pl-3 pr-4 py-3 bg-white border border-gray-300 rounded-xl font-medium text-gray-700 shadow-sm flex items-center justify-between">
|
||||
<span class="capitalize">{format(selectedDate, dateFormat, { locale: dateLocale })}</span>
|
||||
<Calendar size={18} class="text-indigo-600" />
|
||||
</div>
|
||||
<input
|
||||
id="date-select"
|
||||
type="date"
|
||||
bind:value={selectedDateString}
|
||||
class="w-full pl-3 pr-4 py-3 bg-white border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-medium text-gray-700 shadow-sm"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative flex-1 lg:flex-none min-w-[200px]">
|
||||
<label for="floor-select" class="absolute -top-2 left-3 px-1 bg-gray-50 text-[10px] font-bold text-indigo-600 uppercase tracking-wider">Select Floor</label>
|
||||
<label for="floor-select" class="absolute -top-2 left-3 px-1 bg-gray-50 text-[10px] font-bold text-indigo-600 uppercase tracking-wider">{$t('building.selectFloor')}</label>
|
||||
<select
|
||||
id="floor-select"
|
||||
bind:value={selectedFloorId}
|
||||
class="w-full pl-3 pr-10 py-3 bg-white border border-gray-300 rounded-xl appearance-none focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-medium text-gray-700 shadow-sm"
|
||||
>
|
||||
{#each building.floors as floor}
|
||||
<option value={floor.id}>Floor {floor.number}</option>
|
||||
<option value={floor.id}>{$t('building.floor', { values: { number: floor.number } })}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-3 flex items-center pointer-events-none text-gray-400">
|
||||
|
|
@ -126,7 +136,7 @@
|
|||
<a
|
||||
href="/admin/floorplan/{selectedFloorId}"
|
||||
class="p-3 bg-white border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 transition-colors shadow-sm"
|
||||
title="Edit Floorplan"
|
||||
title={$t('building.editFloorplan')}
|
||||
>
|
||||
<Settings size={20} />
|
||||
</a>
|
||||
|
|
@ -138,7 +148,7 @@
|
|||
{#if loadingFloor}
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-600 mb-4"></div>
|
||||
<p class="text-gray-500 font-medium">Switching floor...</p>
|
||||
<p class="text-gray-500 font-medium">{$t('building.switching')}</p>
|
||||
</div>
|
||||
{:else if currentFloor}
|
||||
<div class="flex flex-col items-center">
|
||||
|
|
@ -151,24 +161,24 @@
|
|||
{#if currentFloor.planImageUrl}
|
||||
<p class="mt-6 text-sm text-gray-500 flex items-center gap-2">
|
||||
<MapIcon size={16} />
|
||||
Interactive View - Click a space to see details or book.
|
||||
{$t('building.interactiveView')}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-6 text-sm text-gray-400 flex items-center gap-2 italic">
|
||||
<MapIcon size={16} />
|
||||
No floorplan image uploaded. Showing default grid.
|
||||
{$t('building.noFloorplan')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-gray-400">Select a floor to see the layout</p>
|
||||
<p class="text-gray-400">{$t('building.selectToSee')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-20">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Building not found</h2>
|
||||
<p class="text-gray-500 mt-2">The building you're looking for doesn't exist or you don't have access.</p>
|
||||
<a href="/dashboard" class="mt-8 inline-block text-indigo-600 font-medium">Return to Dashboard</a>
|
||||
<h2 class="text-2xl font-bold text-gray-900">{$t('building.notFound')}</h2>
|
||||
<p class="text-gray-500 mt-2">{$t('building.notFoundDesc')}</p>
|
||||
<a href="/dashboard" class="mt-8 inline-block text-indigo-600 font-medium">{$t('building.return')}</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,17 +4,23 @@
|
|||
import { apiFetch } from "$lib/api/client";
|
||||
import { Calendar, Clock, MapPin, Trash2, Edit2, ChevronRight, X } from "lucide-svelte";
|
||||
import { format } from "date-fns";
|
||||
import { t } from "svelte-i18n";
|
||||
import { es, enUS } from 'date-fns/locale';
|
||||
import { t, locale } from "svelte-i18n";
|
||||
|
||||
let reservations = [];
|
||||
let loading = true;
|
||||
let error = null;
|
||||
|
||||
let editingReservation = null;
|
||||
let editStartTime = "";
|
||||
let editEndTime = "";
|
||||
let editStartTimeDate = "";
|
||||
let editStartTimeTime = "";
|
||||
let editEndTimeDate = "";
|
||||
let editEndTimeTime = "";
|
||||
let saving = false;
|
||||
|
||||
$: dateLocale = $locale === 'es-ES' ? es : enUS;
|
||||
$: dateFormat = $locale === 'es-ES' ? 'dd/MM/yyyy' : 'PPP';
|
||||
|
||||
onMount(async () => {
|
||||
await fetchReservations();
|
||||
});
|
||||
|
|
@ -44,9 +50,14 @@
|
|||
|
||||
function openEditModal(res) {
|
||||
editingReservation = res;
|
||||
// Format for datetime-local input
|
||||
editStartTime = format(new Date(res.startTime), "yyyy-MM-dd'T'HH:mm");
|
||||
editEndTime = format(new Date(res.endTime), "yyyy-MM-dd'T'HH:mm");
|
||||
// Separate date and time for inputs
|
||||
const start = new Date(res.startTime);
|
||||
const end = new Date(res.endTime);
|
||||
|
||||
editStartTimeDate = format(start, "yyyy-MM-dd");
|
||||
editStartTimeTime = format(start, "HH:mm");
|
||||
editEndTimeDate = format(end, "yyyy-MM-dd");
|
||||
editEndTimeTime = format(end, "HH:mm");
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
|
|
@ -56,11 +67,14 @@
|
|||
async function handleUpdate() {
|
||||
saving = true;
|
||||
try {
|
||||
const start = new Date(`${editStartTimeDate}T${editStartTimeTime}`);
|
||||
const end = new Date(`${editEndTimeDate}T${editEndTimeTime}`);
|
||||
|
||||
await apiFetch(`/reservations/${editingReservation.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
startTime: new Date(editStartTime).toISOString(),
|
||||
endTime: new Date(editEndTime).toISOString(),
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
})
|
||||
});
|
||||
await fetchReservations();
|
||||
|
|
@ -120,11 +134,11 @@
|
|||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1">
|
||||
<MapPin size={14} />
|
||||
{res.space.floor.building.name}, Floor {res.space.floor.number}
|
||||
{res.space.floor.building.name}, {$t('myBookings.floor', { values: { number: res.space.floor.number } })}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<Calendar size={14} />
|
||||
{format(new Date(res.startTime), "MMM d, yyyy")}
|
||||
{format(new Date(res.startTime), dateFormat, { locale: dateLocale })}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
|
|
@ -138,14 +152,14 @@
|
|||
<button
|
||||
on:click={() => openEditModal(res)}
|
||||
class="p-2 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
|
||||
title="Edit booking"
|
||||
title={$t('common.edit')}
|
||||
>
|
||||
<Edit2 size={20} />
|
||||
</button>
|
||||
<button
|
||||
on:click={() => deleteReservation(res.id)}
|
||||
class="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Cancel booking"
|
||||
title={$t('common.delete')}
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
|
|
@ -161,7 +175,7 @@
|
|||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div class="bg-white rounded-2xl shadow-xl max-w-md w-full overflow-hidden">
|
||||
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
|
||||
<h3 class="text-xl font-bold text-gray-900">Edit Booking</h3>
|
||||
<h3 class="text-xl font-bold text-gray-900">{$t('myBookings.editBookingTitle')}</h3>
|
||||
<button on:click={closeEditModal} class="text-gray-400 hover:text-gray-600">
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
|
@ -169,32 +183,52 @@
|
|||
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="bg-gray-50 p-4 rounded-xl mb-4">
|
||||
<p class="text-sm font-medium text-gray-500 uppercase tracking-wider">Space</p>
|
||||
<p class="text-sm font-medium text-gray-500 uppercase tracking-wider">{$t('myBookings.space')}</p>
|
||||
<p class="text-lg font-bold text-gray-900">{editingReservation.space.name}</p>
|
||||
<p class="text-sm text-gray-500">{editingReservation.space.floor.building.name}, Floor {editingReservation.space.floor.number}</p>
|
||||
<div class="flex justify-between items-end">
|
||||
<p class="text-sm text-gray-500">{editingReservation.space.floor.building.name}, {$t('myBookings.floor', { values: { number: editingReservation.space.floor.number } })}</p>
|
||||
<p class="text-sm font-bold text-indigo-600">{format(new Date(editingReservation.startTime), dateFormat, { locale: dateLocale })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t('booking.date')}</label>
|
||||
<div class="relative">
|
||||
<div class="w-full px-4 py-2 bg-gray-50 border border-gray-300 rounded-lg text-gray-700 flex justify-between items-center">
|
||||
<span>{format(new Date(editStartTimeDate + 'T00:00:00'), dateFormat, { locale: dateLocale })}</span>
|
||||
<Calendar size={16} class="text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={editStartTimeDate}
|
||||
class="absolute inset-0 opacity-0 cursor-pointer w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Start Time</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t('booking.startTime')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={editStartTime}
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
type="time"
|
||||
bind:value={editStartTimeTime}
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">End Time</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t('booking.endTime')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={editEndTime}
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
type="time"
|
||||
bind:value={editEndTimeTime}
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if editingReservation.space.type === 'DESK'}
|
||||
<p class="text-xs text-amber-600 bg-amber-50 p-2 rounded">
|
||||
Note: Desk bookings are full-day. Times will be automatically adjusted to encompass the entire day.
|
||||
{$t('myBookings.deskNote')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -204,14 +238,14 @@
|
|||
on:click={closeEditModal}
|
||||
class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
on:click={handleUpdate}
|
||||
disabled={saving}
|
||||
class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
{saving ? $t('myBookings.saving') : $t('myBookings.saveChanges')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue