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
Pau Costa Ferrer 2026-03-28 17:58:24 +01:00
parent e03335be67
commit 5fe893c7ce
10 changed files with 380 additions and 129 deletions

View File

@ -4,13 +4,17 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add Docker deployment support for API and Web services&#10;&#10;- Add Dockerfiles for API and Web apps with multi-stage builds.&#10;- Configure `docker-compose.yml` to manage services (Postgres, API, Web, pgAdmin).&#10;- Add `.dockerignore` for optimized builds.&#10;- 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 &#10;&#10;- Extend `Space` model and Prisma schema to include `rotation` property. &#10;- Update API services and DTOs to handle rotation data. &#10;- Add UI controls in `FloorplanEditor.svelte` for rotating spaces. &#10;- 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$/.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/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/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/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/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> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -132,7 +136,7 @@
<updated>1774643312599</updated> <updated>1774643312599</updated>
<workItem from="1774643313611" duration="74000" /> <workItem from="1774643313611" duration="74000" />
<workItem from="1774643422240" duration="5278000" /> <workItem from="1774643422240" duration="5278000" />
<workItem from="1774706361502" duration="10075000" /> <workItem from="1774706361502" duration="10700000" />
</task> </task>
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services."> <task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services.">
<option name="closed" value="true" /> <option name="closed" value="true" />
@ -286,7 +290,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1774715983233</updated> <updated>1774715983233</updated>
</task> </task>
<option name="localTasksCounter" value="20" /> <task id="LOCAL-00020" summary="Add rotation support for spaces in floorplan editor &#10;&#10;- Extend `Space` model and Prisma schema to include `rotation` property. &#10;- Update API services and DTOs to handle rotation data. &#10;- Add UI controls in `FloorplanEditor.svelte` for rotating spaces. &#10;- 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 /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@ -314,6 +326,7 @@
<MESSAGE value="Add i18n support with English and Spanish translations&#10;&#10;- Introduce `svelte-i18n` for internationalization.&#10;- Add English (`en.json`) and Spanish (`es-ES.json`) locale files.&#10;- Implement `setupI18n` function for initializing and managing locales.&#10;- Update project dependencies to include `svelte-i18n`." /> <MESSAGE value="Add i18n support with English and Spanish translations&#10;&#10;- Introduce `svelte-i18n` for internationalization.&#10;- Add English (`en.json`) and Spanish (`es-ES.json`) locale files.&#10;- Implement `setupI18n` function for initializing and managing locales.&#10;- Update project dependencies to include `svelte-i18n`." />
<MESSAGE value="Update prompts to include progress indicator for documentation readability" /> <MESSAGE value="Update prompts to include progress indicator for documentation readability" />
<MESSAGE value="Add Docker deployment support for API and Web services&#10;&#10;- Add Dockerfiles for API and Web apps with multi-stage builds.&#10;- Configure `docker-compose.yml` to manage services (Postgres, API, Web, pgAdmin).&#10;- Add `.dockerignore` for optimized builds.&#10;- Include `DOCKER_SETUP.md` for deployment steps and guidance." /> <MESSAGE value="Add Docker deployment support for API and Web services&#10;&#10;- Add Dockerfiles for API and Web apps with multi-stage builds.&#10;- Configure `docker-compose.yml` to manage services (Postgres, API, Web, pgAdmin).&#10;- Add `.dockerignore` for optimized builds.&#10;- Include `DOCKER_SETUP.md` for deployment steps and guidance." />
<option name="LAST_COMMIT_MESSAGE" value="Add Docker deployment support for API and Web services&#10;&#10;- Add Dockerfiles for API and Web apps with multi-stage builds.&#10;- Configure `docker-compose.yml` to manage services (Postgres, API, Web, pgAdmin).&#10;- Add `.dockerignore` for optimized builds.&#10;- Include `DOCKER_SETUP.md` for deployment steps and guidance." /> <MESSAGE value="Add rotation support for spaces in floorplan editor &#10;&#10;- Extend `Space` model and Prisma schema to include `rotation` property. &#10;- Update API services and DTOs to handle rotation data. &#10;- Add UI controls in `FloorplanEditor.svelte` for rotating spaces. &#10;- 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 &#10;&#10;- Extend `Space` model and Prisma schema to include `rotation` property. &#10;- Update API services and DTOs to handle rotation data. &#10;- Add UI controls in `FloorplanEditor.svelte` for rotating spaces. &#10;- Refactor space rendering logic in both editor and viewer to support rotation." />
</component> </component>
</project> </project>

View File

@ -17,7 +17,8 @@
let loadingReservations = false; let loadingReservations = false;
$: dateLocale = $locale === 'es-ES' ? es : enUS; $: 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) { if (selectedDate) {
@ -82,7 +83,7 @@
end.setHours(eh, em, 0, 0); end.setHours(eh, em, 0, 0);
if (end <= start) { if (end <= start) {
error = "End time must be after start time"; error = $t('booking.errorTime');
loading = false; loading = false;
return; return;
} }
@ -100,7 +101,7 @@
console.log('Booking created successfully'); console.log('Booking created successfully');
dispatch('close'); dispatch('close');
} catch (e: any) { } catch (e: any) {
error = e.message || "Failed to create booking"; error = e.message || $t('booking.errorCreating');
} finally { } finally {
loading = false; loading = false;
} }
@ -121,7 +122,7 @@
}); });
dispatch('close'); dispatch('close');
} catch (e: any) { } catch (e: any) {
error = e.message || "Failed to delete booking"; error = e.message || $t('booking.errorDeleting');
confirmDelete = false; confirmDelete = false;
} finally { } finally {
deleting = false; deleting = false;
@ -252,19 +253,25 @@
<Calendar size={16} /> <Calendar size={16} />
{$t('booking.bookingDate')} {$t('booking.bookingDate')}
</label> </label>
<input <div class="relative">
type="date" <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">
bind:value={bookingDate} <span>{format(new Date(bookingDate + 'T00:00:00'), dateFormat, { locale: dateLocale })}</span>
min={new Date().toISOString().split('T')[0]} <Calendar size={18} class="text-indigo-600" />
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" </div>
/> <input
type="date"
bind:value={bookingDate}
min={new Date().toISOString().split('T')[0]}
class="absolute inset-0 opacity-0 cursor-pointer w-full"
/>
</div>
</div> </div>
{#if space.type === 'MEETING_ROOM'} {#if space.type === 'MEETING_ROOM'}
<div class="space-y-4"> <div class="space-y-4">
<label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2"> <label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2">
<Clock size={16} /> <Clock size={16} />
{$t('booking.availabilityFor', { values: { date: bookingDate } })} {$t('booking.availabilityFor', { values: { date: format(new Date(bookingDate), dateFormat, { locale: dateLocale }) } })}
</label> </label>
{#if loadingReservations} {#if loadingReservations}
@ -327,7 +334,7 @@
</div> </div>
{:else} {:else}
<div class="bg-indigo-50 border border-indigo-100 rounded-xl p-4"> <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> </div>
{/if} {/if}
@ -338,9 +345,9 @@
> >
{#if loading} {#if loading}
<Loader2 class="animate-spin" size={20} /> <Loader2 class="animate-spin" size={20} />
Booking... {$t('booking.booking')}
{:else} {:else}
Confirm Booking {$t('booking.confirm')}
{/if} {/if}
</button> </button>
</div> </div>

View File

@ -5,6 +5,7 @@
import BookingModal from './BookingModal.svelte'; import BookingModal from './BookingModal.svelte';
import Avatar from './Avatar.svelte'; import Avatar from './Avatar.svelte';
import { resolveAssetUrl } from '$lib/utils/url'; import { resolveAssetUrl } from '$lib/utils/url';
import { t } from 'svelte-i18n';
export let floorId: string; export let floorId: string;
export let spaces: any[] = []; export let spaces: any[] = [];
@ -151,7 +152,7 @@
on:click={() => handleSpaceClick(space)} 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'}" 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)" 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 <div
class="flex items-center gap-1 shrink-0 justify-center" class="flex items-center gap-1 shrink-0 justify-center"

View File

@ -27,9 +27,31 @@
"title": "Dashboard", "title": "Dashboard",
"welcome": "Welcome back", "welcome": "Welcome back",
"selectBuilding": "Select a building to see available floors", "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": { "booking": {
"available": "Available",
"book": "Book", "book": "Book",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm Booking", "confirm": "Confirm Booking",
@ -49,6 +71,12 @@
"desk": "Desk", "desk": "Desk",
"amenity": "Amenity", "amenity": "Amenity",
"currentlyBooked": "Currently Booked", "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": { "spaceType": {
"DESK": "Desk", "DESK": "Desk",
"MEETING_ROOM": "Meeting Room", "MEETING_ROOM": "Meeting Room",
@ -64,6 +92,67 @@
"cancelConfirm": "Are you sure you want to cancel this booking?", "cancelConfirm": "Are you sure you want to cancel this booking?",
"cancelFailed": "Failed to cancel booking", "cancelFailed": "Failed to cancel booking",
"updateFailed": "Failed to update 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..."
} }
} }

View File

@ -27,9 +27,31 @@
"title": "Panel de control", "title": "Panel de control",
"welcome": "Bienvenido/a de nuevo", "welcome": "Bienvenido/a de nuevo",
"selectBuilding": "Seleccione un edificio para ver las plantas disponibles", "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": { "booking": {
"available": "Disponible",
"book": "Reservar", "book": "Reservar",
"cancel": "Cancelar", "cancel": "Cancelar",
"confirm": "Confirmar reserva", "confirm": "Confirmar reserva",
@ -49,6 +71,12 @@
"desk": "Puesto", "desk": "Puesto",
"amenity": "Servicio", "amenity": "Servicio",
"currentlyBooked": "Reservado actualmente", "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": { "spaceType": {
"DESK": "Puesto", "DESK": "Puesto",
"MEETING_ROOM": "Sala de reuniones", "MEETING_ROOM": "Sala de reuniones",
@ -64,6 +92,67 @@
"cancelConfirm": "¿Está seguro/a de que desea cancelar esta reserva?", "cancelConfirm": "¿Está seguro/a de que desea cancelar esta reserva?",
"cancelFailed": "Error al cancelar la reserva", "cancelFailed": "Error al cancelar la reserva",
"updateFailed": "Error al actualizar 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..."
} }
} }

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { token } from '$lib/stores/auth'; import { token } from '$lib/stores/auth';
import { apiFetch } from '$lib/api/client'; import { apiFetch } from '$lib/api/client';
@ -32,7 +33,7 @@
'Authorization': `Bearer ${$token}` '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 blob = await res.blob();
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
@ -43,7 +44,7 @@
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} catch (e) { } 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="space-y-6">
<div class="flex justify-between items-center"> <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 <button
on:click={downloadReport} 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" 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"> <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" /> <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> </svg>
<span>Download XLSX Data</span> <span>{$t('reports.downloadData')}</span>
</button> </button>
</div> </div>
@ -80,7 +81,7 @@
<!-- Summary Cards --> <!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <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"> <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> <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="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> <div class="bg-indigo-600 h-2 rounded-full" style="width: {utilizationStats.summary.overallUtilization}%"></div>
@ -88,15 +89,15 @@
</div> </div>
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100"> <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="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>
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100"> <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="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>
</div> </div>
@ -104,7 +105,7 @@
<!-- Building Utilization --> <!-- Building Utilization -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"> <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"> <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>
<div class="p-6"> <div class="p-6">
<div class="space-y-4"> <div class="space-y-4">
@ -126,14 +127,14 @@
<!-- Space Type Distribution --> <!-- Space Type Distribution -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"> <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"> <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>
<div class="p-6"> <div class="p-6">
<div class="space-y-4"> <div class="space-y-4">
{#each utilizationStats.typeStats as typeStat} {#each utilizationStats.typeStats as typeStat}
<div> <div>
<div class="flex justify-between text-sm mb-1"> <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> <span class="text-gray-500">{typeStat.occupied} / {typeStat.total} ({typeStat.utilization.toFixed(1)}%)</span>
</div> </div>
<div class="w-full bg-gray-100 rounded-full h-2"> <div class="w-full bg-gray-100 rounded-full h-2">
@ -148,7 +149,7 @@
<!-- Role Distribution --> <!-- Role Distribution -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"> <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"> <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>
<div class="p-6"> <div class="p-6">
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
@ -165,19 +166,19 @@
<!-- Top Bookers --> <!-- Top Bookers -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"> <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"> <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>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{#each employeeStats.topBookers as booker} {#each employeeStats.topBookers as booker}
<div class="px-6 py-3 flex justify-between items-center hover:bg-gray-50 transition-colors"> <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="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"> <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> </span>
</div> </div>
{/each} {/each}
{#if employeeStats.topBookers.length === 0} {#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} {/if}
</div> </div>
</div> </div>

View File

@ -2,6 +2,12 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { apiFetch } from '$lib/api/client'; import { apiFetch } from '$lib/api/client';
import { token } from '$lib/stores/auth'; 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 users = [];
let loading = true; let loading = true;
@ -33,7 +39,7 @@
}); });
await fetchUsers(); await fetchUsers();
} catch (e) { } 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 }) body: JSON.stringify({ password: newPassword })
}); });
showPasswordModal = false; showPasswordModal = false;
alert('Password updated successfully'); alert($t('users.password.success'));
} catch (e) { } 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}`; inviteToken = `${baseUrl}/register?token=${res.token}`;
showInviteModal = true; showInviteModal = true;
} catch (e) { } 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="space-y-6">
<div class="flex justify-between items-center"> <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"> <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"> <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="EMPLOYEE">{$t('users.roles.EMPLOYEE')}</option>
<option value="SUPERVISOR">Supervisor</option> <option value="SUPERVISOR">{$t('users.roles.SUPERVISOR')}</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">{$t('users.roles.ADMIN')}</option>
</select> </select>
<button <button
on:click={generateInvite} on:click={generateInvite}
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg font-medium transition-colors" 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> </button>
</div> </div>
</div> </div>
{#if loading} {#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> <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> </div>
{:else if error} {:else if error}
<div class="bg-red-50 text-red-700 p-4 rounded-lg border border-red-200"> <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"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <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">{$t('users.table.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">{$t('users.table.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-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">Actions</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> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
@ -121,7 +128,7 @@
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{user.email}</div> <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>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<select <select
@ -129,20 +136,20 @@
on:change={(e) => updateUserRole(user.id, e.target.value)} 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" 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="EMPLOYEE">{$t('users.roles.EMPLOYEE')}</option>
<option value="SUPERVISOR">Supervisor</option> <option value="SUPERVISOR">{$t('users.roles.SUPERVISOR')}</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">{$t('users.roles.ADMIN')}</option>
</select> </select>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <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>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button <button
on:click={() => openPasswordModal(user)} on:click={() => openPasswordModal(user)}
class="text-indigo-600 hover:text-indigo-900 bg-indigo-50 px-3 py-1 rounded-md" class="text-indigo-600 hover:text-indigo-900 bg-indigo-50 px-3 py-1 rounded-md"
> >
Change Password {$t('users.password.change')}
</button> </button>
</td> </td>
</tr> </tr>
@ -157,8 +164,8 @@
{#if showInviteModal} {#if showInviteModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <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"> <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> <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">Anyone with this link can register as a <strong>{inviteRole}</strong> in your organization.</p> <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"> <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" /> <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" 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} {#if copied}
<span class="text-green-600">Copied!</span> <span class="text-green-600">{$t('users.invite.copied')}</span>
{:else} {:else}
<span>Copy</span> <span>{$t('users.invite.copy')}</span>
{/if} {/if}
</button> </button>
</div> </div>
@ -179,7 +186,7 @@
on:click={() => showInviteModal = false} on:click={() => showInviteModal = false}
class="bg-gray-900 text-white px-6 py-2 rounded-lg font-medium" class="bg-gray-900 text-white px-6 py-2 rounded-lg font-medium"
> >
Done {$t('users.invite.done')}
</button> </button>
</div> </div>
</div> </div>
@ -190,17 +197,17 @@
{#if showPasswordModal} {#if showPasswordModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <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"> <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> <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">Set a new password for <strong>{selectedUser?.email}</strong></p> <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 class="space-y-4 mb-6">
<div> <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 <input
type="password" type="password"
id="new-password" id="new-password"
bind:value={newPassword} 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" class="w-full rounded-lg border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"
/> />
</div> </div>
@ -211,14 +218,14 @@
on:click={() => showPasswordModal = false} on:click={() => showPasswordModal = false}
class="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 rounded-lg" class="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 rounded-lg"
> >
Cancel {$t('common.cancel')}
</button> </button>
<button <button
on:click={updatePassword} on:click={updatePassword}
disabled={newPassword.length < 6} 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" 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> </button>
</div> </div>
</div> </div>

View File

@ -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" 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} /> <Plus size={20} />
Add Building {$t('dashboard.addBuilding')}
</button> </button>
{/if} {/if}
</div> </div>
@ -86,7 +86,7 @@
<button <button
on:click={() => openEditModal(building)} on:click={() => openEditModal(building)}
class="p-2 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" 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} /> <Edit2 size={18} />
</button> </button>
@ -97,14 +97,14 @@
<p class="text-gray-500 text-sm mb-4 line-clamp-2">{building.description}</p> <p class="text-gray-500 text-sm mb-4 line-clamp-2">{building.description}</p>
{/if} {/if}
<p class="text-gray-400 text-xs mb-6 uppercase tracking-wider font-semibold"> <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> </p>
<a <a
href="/dashboard/building/{building.id}" 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" 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} /> <ArrowRight size={18} />
</a> </a>
</div> </div>
@ -114,8 +114,8 @@
{#if buildings.length === 0} {#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"> <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} /> <Building2 class="mx-auto text-gray-400 mb-4" size={48} />
<h3 class="text-lg font-medium text-gray-900">No buildings found</h3> <h3 class="text-lg font-medium text-gray-900">{$t('dashboard.noBuildings')}</h3>
<p class="text-gray-500 mt-1">Your organization doesn't have any buildings assigned yet.</p> <p class="text-gray-500 mt-1">{$t('dashboard.noBuildingsDesc')}</p>
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -6,6 +6,9 @@
import { apiFetch } from "$lib/api/client"; import { apiFetch } from "$lib/api/client";
import FloorplanViewer from '$lib/components/FloorplanViewer.svelte'; import FloorplanViewer from '$lib/components/FloorplanViewer.svelte';
import { Building2, Layers, Map as MapIcon, ChevronLeft, Settings, Calendar } from "lucide-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 building: any = null;
let currentFloor: 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 () => { onMount(async () => {
const buildingId = $page.params.id; const buildingId = $page.params.id;
if (!$token) { if (!$token) {
@ -69,14 +75,14 @@
<div class="mb-6"> <div class="mb-6">
<a href="/dashboard" class="inline-flex items-center text-indigo-600 hover:text-indigo-700 font-medium gap-1"> <a href="/dashboard" class="inline-flex items-center text-indigo-600 hover:text-indigo-700 font-medium gap-1">
<ChevronLeft size={20} /> <ChevronLeft size={20} />
Back to Dashboard {$t('building.back')}
</a> </a>
</div> </div>
{#if loading} {#if loading}
<div class="flex flex-col items-center justify-center h-64"> <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> <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> </div>
{:else if building} {:else if building}
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center mb-8 gap-4"> <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"> <div class="flex items-center gap-4 mt-1 text-gray-500">
<span class="flex items-center gap-1.5 text-sm"> <span class="flex items-center gap-1.5 text-sm">
<Layers size={16} /> <Layers size={16} />
{building.floors?.length || 0} Floors {$t('dashboard.floors', { values: { count: building.floors?.length || 0 } })}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div class="flex items-center gap-3 w-full lg:w-auto"> <div class="flex items-center gap-3 w-full lg:w-auto">
<div class="relative flex-1 lg:flex-none min-w-[180px]"> <div class="relative flex-1 lg:flex-none min-w-[200px]">
<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> <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 <input
id="date-select" id="date-select"
type="date" type="date"
bind:value={selectedDateString} 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>
<div class="relative flex-1 lg:flex-none min-w-[200px]"> <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 <select
id="floor-select" id="floor-select"
bind:value={selectedFloorId} 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" 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} {#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} {/each}
</select> </select>
<div class="absolute inset-y-0 right-3 flex items-center pointer-events-none text-gray-400"> <div class="absolute inset-y-0 right-3 flex items-center pointer-events-none text-gray-400">
@ -126,7 +136,7 @@
<a <a
href="/admin/floorplan/{selectedFloorId}" 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" 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} /> <Settings size={20} />
</a> </a>
@ -138,7 +148,7 @@
{#if loadingFloor} {#if loadingFloor}
<div class="flex flex-col items-center"> <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> <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> </div>
{:else if currentFloor} {:else if currentFloor}
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
@ -151,24 +161,24 @@
{#if currentFloor.planImageUrl} {#if currentFloor.planImageUrl}
<p class="mt-6 text-sm text-gray-500 flex items-center gap-2"> <p class="mt-6 text-sm text-gray-500 flex items-center gap-2">
<MapIcon size={16} /> <MapIcon size={16} />
Interactive View - Click a space to see details or book. {$t('building.interactiveView')}
</p> </p>
{:else} {:else}
<p class="mt-6 text-sm text-gray-400 flex items-center gap-2 italic"> <p class="mt-6 text-sm text-gray-400 flex items-center gap-2 italic">
<MapIcon size={16} /> <MapIcon size={16} />
No floorplan image uploaded. Showing default grid. {$t('building.noFloorplan')}
</p> </p>
{/if} {/if}
</div> </div>
{:else} {:else}
<p class="text-gray-400">Select a floor to see the layout</p> <p class="text-gray-400">{$t('building.selectToSee')}</p>
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="text-center py-20"> <div class="text-center py-20">
<h2 class="text-2xl font-bold text-gray-900">Building not found</h2> <h2 class="text-2xl font-bold text-gray-900">{$t('building.notFound')}</h2>
<p class="text-gray-500 mt-2">The building you're looking for doesn't exist or you don't have access.</p> <p class="text-gray-500 mt-2">{$t('building.notFoundDesc')}</p>
<a href="/dashboard" class="mt-8 inline-block text-indigo-600 font-medium">Return to Dashboard</a> <a href="/dashboard" class="mt-8 inline-block text-indigo-600 font-medium">{$t('building.return')}</a>
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -4,17 +4,23 @@
import { apiFetch } from "$lib/api/client"; import { apiFetch } from "$lib/api/client";
import { Calendar, Clock, MapPin, Trash2, Edit2, ChevronRight, X } from "lucide-svelte"; import { Calendar, Clock, MapPin, Trash2, Edit2, ChevronRight, X } from "lucide-svelte";
import { format } from "date-fns"; 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 reservations = [];
let loading = true; let loading = true;
let error = null; let error = null;
let editingReservation = null; let editingReservation = null;
let editStartTime = ""; let editStartTimeDate = "";
let editEndTime = ""; let editStartTimeTime = "";
let editEndTimeDate = "";
let editEndTimeTime = "";
let saving = false; let saving = false;
$: dateLocale = $locale === 'es-ES' ? es : enUS;
$: dateFormat = $locale === 'es-ES' ? 'dd/MM/yyyy' : 'PPP';
onMount(async () => { onMount(async () => {
await fetchReservations(); await fetchReservations();
}); });
@ -44,9 +50,14 @@
function openEditModal(res) { function openEditModal(res) {
editingReservation = res; editingReservation = res;
// Format for datetime-local input // Separate date and time for inputs
editStartTime = format(new Date(res.startTime), "yyyy-MM-dd'T'HH:mm"); const start = new Date(res.startTime);
editEndTime = format(new Date(res.endTime), "yyyy-MM-dd'T'HH:mm"); 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() { function closeEditModal() {
@ -56,11 +67,14 @@
async function handleUpdate() { async function handleUpdate() {
saving = true; saving = true;
try { try {
const start = new Date(`${editStartTimeDate}T${editStartTimeTime}`);
const end = new Date(`${editEndTimeDate}T${editEndTimeTime}`);
await apiFetch(`/reservations/${editingReservation.id}`, { await apiFetch(`/reservations/${editingReservation.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body: JSON.stringify({
startTime: new Date(editStartTime).toISOString(), startTime: start.toISOString(),
endTime: new Date(editEndTime).toISOString(), endTime: end.toISOString(),
}) })
}); });
await fetchReservations(); 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"> <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"> <span class="flex items-center gap-1">
<MapPin size={14} /> <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>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<Calendar size={14} /> <Calendar size={14} />
{format(new Date(res.startTime), "MMM d, yyyy")} {format(new Date(res.startTime), dateFormat, { locale: dateLocale })}
</span> </span>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<Clock size={14} /> <Clock size={14} />
@ -138,14 +152,14 @@
<button <button
on:click={() => openEditModal(res)} on:click={() => openEditModal(res)}
class="p-2 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" 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} /> <Edit2 size={20} />
</button> </button>
<button <button
on:click={() => deleteReservation(res.id)} on:click={() => deleteReservation(res.id)}
class="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" 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} /> <Trash2 size={20} />
</button> </button>
@ -161,7 +175,7 @@
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"> <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="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"> <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"> <button on:click={closeEditModal} class="text-gray-400 hover:text-gray-600">
<X size={24} /> <X size={24} />
</button> </button>
@ -169,32 +183,52 @@
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<div class="bg-gray-50 p-4 rounded-xl mb-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-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>
<div> <div class="grid grid-cols-2 gap-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Start Time</label> <div class="col-span-2">
<input <label class="block text-sm font-medium text-gray-700 mb-1">{$t('booking.date')}</label>
type="datetime-local" <div class="relative">
bind:value={editStartTime} <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">
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" <span>{format(new Date(editStartTimeDate + 'T00:00:00'), dateFormat, { locale: dateLocale })}</span>
/> <Calendar size={16} class="text-gray-400" />
</div> </div>
<input
type="date"
bind:value={editStartTimeDate}
class="absolute inset-0 opacity-0 cursor-pointer w-full"
/>
</div>
</div>
<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.startTime')}</label>
<input <input
type="datetime-local" type="time"
bind:value={editEndTime} 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" 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">{$t('booking.endTime')}</label>
<input
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> </div>
{#if editingReservation.space.type === 'DESK'} {#if editingReservation.space.type === 'DESK'}
<p class="text-xs text-amber-600 bg-amber-50 p-2 rounded"> <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> </p>
{/if} {/if}
</div> </div>
@ -204,14 +238,14 @@
on:click={closeEditModal} 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" 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>
<button <button
on:click={handleUpdate} on:click={handleUpdate}
disabled={saving} 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" 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> </button>
</div> </div>
</div> </div>