Compare commits

1 Commits

Author SHA1 Message Date
Robert van Diest
20b6bb3d8f Add file 2026-03-27 16:22:02 +01:00
55 changed files with 671 additions and 2387 deletions

View File

@@ -1,31 +0,0 @@
# Git Commit
Look at all staged changes using `git diff --cached` and create a descriptive git commit message that follows these rules:
- Use conventional commit format: `type(scope): description`
- Types: feat, fix, docs, refactor, test, chore
- Keep the subject line under 72 characters
- Add a short body if the change is complex and needs explanation
- Be specific about what changed and why
Then run the commit with that message.
## Examples of good commit messages
- `feat(auth): add JWT token refresh endpoint`
- `fix(api): handle null response from payment gateway`
- `refactor(db): extract connection pooling into separate module`
- `docs(readme): add setup instructions for Windows`
```
---
**How to run it**
In Claude Code:
```
/commit
```
Or with a hint:
```
/commit this fixes the login bug we discussed

View File

@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(dotnet new:*)",
"Bash(dotnet sln:*)",
"Bash(dotnet add:*)",
"Bash(dotnet build:*)",
"Bash(dotnet tool:*)",
"Bash(dotnet ef:*)",
"Bash(timeout 8 dotnet run)",
"Bash(npm create:*)",
"Bash(npm install:*)",
"Bash(npm run:*)",
"Bash(npx playwright:*)",
"Bash(dotnet test:*)"
]
}
}

View File

@@ -53,8 +53,6 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build images - name: Build images
run: docker compose -f cicd/docker/docker-compose.yml build run: docker compose -f cicd/docker/docker-compose.yml build

7
.gitignore vendored
View File

@@ -52,10 +52,3 @@ tests/e2e/.cache/
# ------------------------- # -------------------------
*.log *.log
logs/ logs/
# -------------------------
# Claude
# -------------------------
.claude/settings.local.json
.claude/cache/
.claude/sessions/

96
CLAUDE.md Normal file
View File

@@ -0,0 +1,96 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Randall** is an office desk reservation system. It uses a .NET 10 backend (Clean Architecture) with a React 19 + TypeScript frontend, served via Nginx reverse proxy in Docker.
Default admin: `admin@randall.local` / `Admin@123`
## Commands
### Backend
```bash
# Run (port 5180)
dotnet run --project src/backend/src/Randall.Api
# Unit tests
dotnet test src/backend/tests/unit/Randall.Domain.UnitTests
# Integration tests
dotnet test src/backend/tests/integration/Randall.Api.IntegrationTests
# All backend tests
dotnet test src/backend
```
### Frontend
```bash
cd src/frontend
npm run dev # Dev server (port 5173, proxies /api → localhost:5180)
npm run build
npm run lint
```
### E2E Tests
```bash
cd tests/e2e
npx playwright test
npx playwright test --headed
npx playwright test --ui
```
### Docker
```bash
docker compose -f cicd/docker/docker-compose.yml up --build
# Runs on port 80; backend health check at :8080/health
```
## Architecture
### Backend — Clean Architecture (4 layers)
| Layer | Project | Role |
|-------|---------|------|
| API | `Randall.Api` | Controllers, JWT middleware, request/response |
| Application | `Randall.Application` | Use-case handlers, DTOs, DI registration |
| Domain | `Randall.Domain` | Entities, business rules, `Result<T>` pattern |
| Infrastructure | `Randall.Infrastructure` | EF Core, SQLite, JWT generation, password hashing |
**Key patterns:**
- **Result pattern:** `Result<T>` (railway-oriented error handling) used throughout domain and application layers.
- **Repository pattern:** interfaces in Domain, implementations in Infrastructure.
- **Handlers:** each use case has a dedicated handler class in Application (organized by feature: Admin, Auth, Reservations, Workplaces).
**Domain constraints:**
- One active reservation per employee per day
- One active reservation per workplace per day
**Database:** SQLite at `randall.db` (Docker: `/app/data/randall.db`). Migrations apply automatically on startup. Seeded with 16 workplaces + admin account on first run (skipped if DB is non-empty).
### Frontend
- **Pages:** `AuthPage`, `PlannerPage`, `AdminPage` — routed via React Router
- **API client:** `src/api/client.ts` — centralized typed fetch wrapper
- **Auth state:** JWT stored in localStorage, passed as Bearer token
- **Dev proxy:** Vite proxies `/api` to backend, so no CORS issues locally
### Request Flow
```
React SPA → Nginx (/api/* proxied) → ASP.NET Core Controller
→ Application Handler → Domain Entity → Infrastructure (EF Core / SQLite)
```
### Testing Strategy
| Layer | Framework | Approach |
|-------|-----------|----------|
| Unit | xUnit | Domain entities in isolation |
| Integration | xUnit + WebApplicationFactory | Full API endpoints with in-process SQLite |
| E2E | Playwright | Full user journeys through browser |
### CI/CD (GitHub Actions)
Pipeline jobs run in order: unit tests → integration tests → E2E (Docker-based) → deploy (main branch only via SSH + Docker Compose).

View File

@@ -8,14 +8,12 @@ COPY src/backend/src/Randall.Domain/Randall.Domain.csproj s
COPY src/backend/src/Randall.Application/Randall.Application.csproj src/backend/src/Randall.Application/ COPY src/backend/src/Randall.Application/Randall.Application.csproj src/backend/src/Randall.Application/
COPY src/backend/src/Randall.Infrastructure/Randall.Infrastructure.csproj src/backend/src/Randall.Infrastructure/ COPY src/backend/src/Randall.Infrastructure/Randall.Infrastructure.csproj src/backend/src/Randall.Infrastructure/
COPY src/backend/src/Randall.Api/Randall.Api.csproj src/backend/src/Randall.Api/ COPY src/backend/src/Randall.Api/Randall.Api.csproj src/backend/src/Randall.Api/
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \ RUN dotnet restore "src/backend/src/Randall.Api/Randall.Api.csproj"
dotnet restore "src/backend/src/Randall.Api/Randall.Api.csproj"
# Build — copy source only (no bin/obj/db files) # Build — copy source only (no bin/obj/db files)
COPY src/backend/src/ src/backend/src/ COPY src/backend/src/ src/backend/src/
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \ RUN dotnet publish "src/backend/src/Randall.Api/Randall.Api.csproj" \
dotnet publish "src/backend/src/Randall.Api/Randall.Api.csproj" \ -c Release -o /app/publish
-c Release --no-restore -o /app/publish
# Runtime stage # Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime

View File

@@ -1,11 +1,10 @@
# Build stage # Build stage
FROM public.ecr.aws/docker/library/node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
# Install dependencies — copy lockfiles first to cache the npm layer # Install dependencies — copy lockfiles first to cache the npm layer
COPY src/frontend/package.json src/frontend/package-lock.json ./ COPY src/frontend/package.json src/frontend/package-lock.json ./
RUN --mount=type=cache,id=npm,target=/root/.npm \ RUN npm ci
npm ci --prefer-offline
# Copy source # Copy source
COPY src/frontend/index.html ./ COPY src/frontend/index.html ./
@@ -18,7 +17,7 @@ COPY src/frontend/public/ ./public/
RUN npm run build RUN npm run build
# Serve stage # Serve stage
FROM public.ecr.aws/docker/library/nginx:alpine AS runtime FROM nginx:alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY cicd/docker/nginx.conf /etc/nginx/conf.d/default.conf COPY cicd/docker/nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -1,6 +1,5 @@
services: services:
backend: backend:
image: localhost/randall/backend:latest
build: build:
context: ../.. context: ../..
dockerfile: cicd/docker/Dockerfile.backend dockerfile: cicd/docker/Dockerfile.backend
@@ -21,7 +20,6 @@ services:
start_period: 10s start_period: 10s
frontend: frontend:
image: localhost/randall/frontend:latest
build: build:
context: ../.. context: ../..
dockerfile: cicd/docker/Dockerfile.frontend dockerfile: cicd/docker/Dockerfile.frontend

View File

@@ -1,7 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Randall.Application.Admin.AddUser;
using Randall.Application.Admin.ApproveUser; using Randall.Application.Admin.ApproveUser;
using Randall.Application.Admin.DeleteUser; using Randall.Application.Admin.DeleteUser;
using Randall.Application.Admin.GetAllUsers; using Randall.Application.Admin.GetAllUsers;
@@ -10,13 +9,10 @@ using Randall.Application.Admin.MakeAdmin;
namespace Randall.Api.Admin; namespace Randall.Api.Admin;
public record AddUserRequest(string Email, string Name, string Password);
[ApiController] [ApiController]
[Route("api/admin")] [Route("api/admin")]
[Authorize] [Authorize]
public class AdminController( public class AdminController(
AddUserHandler addUserHandler,
GetAllUsersHandler getAllUsersHandler, GetAllUsersHandler getAllUsersHandler,
GetPendingUsersHandler getPendingUsersHandler, GetPendingUsersHandler getPendingUsersHandler,
ApproveUserHandler approveUserHandler, ApproveUserHandler approveUserHandler,
@@ -27,16 +23,6 @@ public class AdminController(
private Guid RequesterId => Guid.Parse(User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier) private Guid RequesterId => Guid.Parse(User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier)
?? User.FindFirstValue("sub")!); ?? User.FindFirstValue("sub")!);
[HttpPost("users")]
public async Task<IActionResult> AddUser([FromBody] AddUserRequest request, CancellationToken ct)
{
if (!IsAdmin) return Forbid();
var result = await addUserHandler.HandleAsync(new AddUserCommand(request.Email, request.Name, request.Password), ct);
if (!result.IsSuccess)
return BadRequest(new ProblemDetails { Detail = result.Error });
return CreatedAtAction(nameof(GetAllUsers), result.Value);
}
[HttpGet("users")] [HttpGet("users")]
public async Task<IActionResult> GetAllUsers(CancellationToken ct) public async Task<IActionResult> GetAllUsers(CancellationToken ct)
{ {

View File

@@ -1,3 +0,0 @@
namespace Randall.Application.Admin.AddUser;
public record AddUserCommand(string Email, string Name, string Password);

View File

@@ -1,30 +0,0 @@
using Randall.Application.Admin.GetAllUsers;
using Randall.Application.Common;
using Randall.Domain.Common;
using Randall.Domain.Users;
namespace Randall.Application.Admin.AddUser;
public class AddUserHandler(IUserRepository userRepository, IPasswordHasher passwordHasher)
{
public async Task<Result<AdminUserDto>> HandleAsync(AddUserCommand command, CancellationToken ct = default)
{
var exists = await userRepository.ExistsByEmailAsync(command.Email, ct);
if (exists)
return Result.Failure<AdminUserDto>("An account with this email already exists.");
var hash = passwordHasher.Hash(command.Password);
var result = User.Create(command.Email, command.Name, hash);
if (!result.IsSuccess)
return Result.Failure<AdminUserDto>(result.Error!);
var user = result.Value!;
user.Approve();
await userRepository.AddAsync(user, ct);
await userRepository.SaveChangesAsync(ct);
return Result.Success(new AdminUserDto(user.Id, user.Name, user.Email, user.IsApproved, user.IsAdmin));
}
}

View File

@@ -1,5 +1,4 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Randall.Application.Admin.AddUser;
using Randall.Application.Admin.ApproveUser; using Randall.Application.Admin.ApproveUser;
using Randall.Application.Admin.DeleteUser; using Randall.Application.Admin.DeleteUser;
using Randall.Application.Admin.GetAllUsers; using Randall.Application.Admin.GetAllUsers;
@@ -23,7 +22,6 @@ public static class DependencyInjection
{ {
services.AddScoped<RegisterHandler>(); services.AddScoped<RegisterHandler>();
services.AddScoped<LoginHandler>(); services.AddScoped<LoginHandler>();
services.AddScoped<AddUserHandler>();
services.AddScoped<GetAllUsersHandler>(); services.AddScoped<GetAllUsersHandler>();
services.AddScoped<GetPendingUsersHandler>(); services.AddScoped<GetPendingUsersHandler>();
services.AddScoped<ApproveUserHandler>(); services.AddScoped<ApproveUserHandler>();

View File

@@ -13,16 +13,11 @@ public class Reservation : Entity
public ReservationStatus Status { get; private set; } public ReservationStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; } public DateTime CreatedAt { get; private set; }
internal Reservation( // EF Core constructor
Guid id, Guid workplaceId, string employeeEmail, string employeeName, private Reservation() : base()
DateOnly date, ReservationStatus status, DateTime createdAt) : base(id)
{ {
WorkplaceId = workplaceId; EmployeeEmail = string.Empty;
EmployeeEmail = employeeEmail; EmployeeName = string.Empty;
EmployeeName = employeeName;
Date = date;
Status = status;
CreatedAt = createdAt;
} }
private Reservation(Guid workplaceId, string employeeEmail, string employeeName, DateOnly date) private Reservation(Guid workplaceId, string employeeEmail, string employeeName, DateOnly date)

View File

@@ -1,9 +0,0 @@
namespace Randall.Domain.Reservations;
public static class ReservationFactory
{
public static Reservation Reconstitute(
Guid id, Guid workplaceId, string employeeEmail, string employeeName,
DateOnly date, ReservationStatus status, DateTime createdAt) =>
new(id, workplaceId, employeeEmail, employeeName, date, status, createdAt);
}

View File

@@ -10,13 +10,11 @@ public class User : Entity
public bool IsApproved { get; private set; } public bool IsApproved { get; private set; }
public bool IsAdmin { get; private set; } public bool IsAdmin { get; private set; }
internal User(Guid id, string email, string name, string passwordHash, bool isApproved, bool isAdmin) : base(id) private User() : base()
{ {
Email = email; Email = string.Empty;
Name = name; Name = string.Empty;
PasswordHash = passwordHash; PasswordHash = string.Empty;
IsApproved = isApproved;
IsAdmin = isAdmin;
} }
private User(string email, string name, string passwordHash, bool isAdmin) : base() private User(string email, string name, string passwordHash, bool isAdmin) : base()

View File

@@ -1,7 +0,0 @@
namespace Randall.Domain.Users;
public static class UserFactory
{
public static User Reconstitute(Guid id, string email, string name, string passwordHash, bool isApproved, bool isAdmin) =>
new(id, email, name, passwordHash, isApproved, isAdmin);
}

View File

@@ -8,11 +8,10 @@ public class Workplace : Entity
public string Location { get; private set; } public string Location { get; private set; }
public bool IsActive { get; private set; } public bool IsActive { get; private set; }
internal Workplace(Guid id, string name, string location, bool isActive) : base(id) private Workplace() : base()
{ {
Name = name; Name = string.Empty;
Location = location; Location = string.Empty;
IsActive = isActive;
} }
public Workplace(string name, string location) : base() public Workplace(string name, string location) : base()

View File

@@ -1,7 +0,0 @@
namespace Randall.Domain.Workplaces;
public static class WorkplaceFactory
{
public static Workplace Reconstitute(Guid id, string name, string location, bool isActive) =>
new(id, name, location, isActive);
}

View File

@@ -1,120 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Randall.Infrastructure.Persistence;
#nullable disable
namespace Randall.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260327154255_SeparateStorageModels")]
partial class SeparateStorageModels
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.ReservationRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("EmployeeEmail")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("EmployeeName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<Guid>("WorkplaceId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EmployeeEmail", "Date");
b.HasIndex("WorkplaceId", "Date");
b.ToTable("Reservations", (string)null);
});
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.UserRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsApproved")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.WorkplaceRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Workplaces", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Randall.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class SeparateStorageModels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -17,7 +17,7 @@ namespace Randall.Infrastructure.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.ReservationRecord", b => modelBuilder.Entity("Randall.Domain.Reservations.Reservation", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -51,10 +51,10 @@ namespace Randall.Infrastructure.Migrations
b.HasIndex("WorkplaceId", "Date"); b.HasIndex("WorkplaceId", "Date");
b.ToTable("Reservations", (string)null); b.ToTable("Reservations");
}); });
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.UserRecord", b => modelBuilder.Entity("Randall.Domain.Users.User", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -85,10 +85,10 @@ namespace Randall.Infrastructure.Migrations
b.HasIndex("Email") b.HasIndex("Email")
.IsUnique(); .IsUnique();
b.ToTable("Users", (string)null); b.ToTable("Users");
}); });
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.WorkplaceRecord", b => modelBuilder.Entity("Randall.Domain.Workplaces.Workplace", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -109,7 +109,7 @@ namespace Randall.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Workplaces", (string)null); b.ToTable("Workplaces");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }

View File

@@ -1,28 +1,28 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Randall.Infrastructure.Persistence.Records; using Randall.Domain.Reservations;
using Randall.Domain.Users;
using Randall.Domain.Workplaces;
namespace Randall.Infrastructure.Persistence; namespace Randall.Infrastructure.Persistence;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options) public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{ {
public DbSet<WorkplaceRecord> Workplaces => Set<WorkplaceRecord>(); public DbSet<Workplace> Workplaces => Set<Workplace>();
public DbSet<ReservationRecord> Reservations => Set<ReservationRecord>(); public DbSet<Reservation> Reservations => Set<Reservation>();
public DbSet<UserRecord> Users => Set<UserRecord>(); public DbSet<User> Users => Set<User>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<WorkplaceRecord>(entity => modelBuilder.Entity<Workplace>(entity =>
{ {
entity.ToTable("Workplaces");
entity.HasKey(w => w.Id); entity.HasKey(w => w.Id);
entity.Property(w => w.Name).IsRequired().HasMaxLength(100); entity.Property(w => w.Name).IsRequired().HasMaxLength(100);
entity.Property(w => w.Location).IsRequired().HasMaxLength(200); entity.Property(w => w.Location).IsRequired().HasMaxLength(200);
entity.Property(w => w.IsActive).IsRequired(); entity.Property(w => w.IsActive).IsRequired();
}); });
modelBuilder.Entity<ReservationRecord>(entity => modelBuilder.Entity<Reservation>(entity =>
{ {
entity.ToTable("Reservations");
entity.HasKey(r => r.Id); entity.HasKey(r => r.Id);
entity.Property(r => r.WorkplaceId).IsRequired(); entity.Property(r => r.WorkplaceId).IsRequired();
entity.Property(r => r.EmployeeEmail).IsRequired().HasMaxLength(200); entity.Property(r => r.EmployeeEmail).IsRequired().HasMaxLength(200);
@@ -35,9 +35,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
entity.HasIndex(r => new { r.EmployeeEmail, r.Date }); entity.HasIndex(r => new { r.EmployeeEmail, r.Date });
}); });
modelBuilder.Entity<UserRecord>(entity => modelBuilder.Entity<User>(entity =>
{ {
entity.ToTable("Users");
entity.HasKey(u => u.Id); entity.HasKey(u => u.Id);
entity.Property(u => u.Email).IsRequired().HasMaxLength(200); entity.Property(u => u.Email).IsRequired().HasMaxLength(200);
entity.Property(u => u.Name).IsRequired().HasMaxLength(200); entity.Property(u => u.Name).IsRequired().HasMaxLength(200);

View File

@@ -1,34 +0,0 @@
using Randall.Domain.Reservations;
using Randall.Infrastructure.Persistence.Records;
namespace Randall.Infrastructure.Persistence.Mappers;
public static class ReservationMapper
{
public static Reservation ToDomain(ReservationRecord record) =>
ReservationFactory.Reconstitute(
record.Id,
record.WorkplaceId,
record.EmployeeEmail,
record.EmployeeName,
record.Date,
record.Status,
record.CreatedAt);
public static ReservationRecord ToRecord(Reservation domain) =>
new()
{
Id = domain.Id,
WorkplaceId = domain.WorkplaceId,
EmployeeEmail = domain.EmployeeEmail,
EmployeeName = domain.EmployeeName,
Date = domain.Date,
Status = domain.Status,
CreatedAt = domain.CreatedAt,
};
public static void SyncToRecord(Reservation domain, ReservationRecord record)
{
record.Status = domain.Status;
}
}

View File

@@ -1,33 +0,0 @@
using Randall.Domain.Users;
using Randall.Infrastructure.Persistence.Records;
namespace Randall.Infrastructure.Persistence.Mappers;
public static class UserMapper
{
public static User ToDomain(UserRecord record) =>
UserFactory.Reconstitute(
record.Id,
record.Email,
record.Name,
record.PasswordHash,
record.IsApproved,
record.IsAdmin);
public static UserRecord ToRecord(User domain) =>
new()
{
Id = domain.Id,
Email = domain.Email,
Name = domain.Name,
PasswordHash = domain.PasswordHash,
IsApproved = domain.IsApproved,
IsAdmin = domain.IsAdmin,
};
public static void SyncToRecord(User domain, UserRecord record)
{
record.IsApproved = domain.IsApproved;
record.IsAdmin = domain.IsAdmin;
}
}

View File

@@ -1,23 +0,0 @@
using Randall.Domain.Workplaces;
using Randall.Infrastructure.Persistence.Records;
namespace Randall.Infrastructure.Persistence.Mappers;
public static class WorkplaceMapper
{
public static Workplace ToDomain(WorkplaceRecord record) =>
WorkplaceFactory.Reconstitute(
record.Id,
record.Name,
record.Location,
record.IsActive);
public static WorkplaceRecord ToRecord(Workplace domain) =>
new()
{
Id = domain.Id,
Name = domain.Name,
Location = domain.Location,
IsActive = domain.IsActive,
};
}

View File

@@ -1,14 +0,0 @@
using Randall.Domain.Reservations;
namespace Randall.Infrastructure.Persistence.Records;
public class ReservationRecord
{
public Guid Id { get; set; }
public Guid WorkplaceId { get; set; }
public string EmployeeEmail { get; set; } = string.Empty;
public string EmployeeName { get; set; } = string.Empty;
public DateOnly Date { get; set; }
public ReservationStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@@ -1,11 +0,0 @@
namespace Randall.Infrastructure.Persistence.Records;
public class UserRecord
{
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public bool IsApproved { get; set; }
public bool IsAdmin { get; set; }
}

View File

@@ -1,9 +0,0 @@
namespace Randall.Infrastructure.Persistence.Records;
public class WorkplaceRecord
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Location { get; set; } = string.Empty;
public bool IsActive { get; set; }
}

View File

@@ -1,40 +1,23 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Randall.Domain.Reservations; using Randall.Domain.Reservations;
using Randall.Infrastructure.Persistence.Mappers;
using Randall.Infrastructure.Persistence.Records;
namespace Randall.Infrastructure.Persistence.Reservations; namespace Randall.Infrastructure.Persistence.Reservations;
public class ReservationRepository(AppDbContext context) : IReservationRepository public class ReservationRepository(AppDbContext context) : IReservationRepository
{ {
private readonly Dictionary<Guid, (Reservation Domain, ReservationRecord Record)> _tracked = []; public Task<Reservation?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
context.Reservations.FirstOrDefaultAsync(r => r.Id == id, ct);
public async Task<Reservation?> GetByIdAsync(Guid id, CancellationToken ct = default) public async Task<IReadOnlyList<Reservation>> GetByEmployeeAsync(string employeeEmail, CancellationToken ct = default) =>
{ await context.Reservations
var record = await context.Reservations.FirstOrDefaultAsync(r => r.Id == id, ct); .Where(r => r.EmployeeEmail.ToLower() == employeeEmail.ToLower())
if (record is null) return null;
var domain = ReservationMapper.ToDomain(record);
_tracked[domain.Id] = (domain, record);
return domain;
}
public async Task<IReadOnlyList<Reservation>> GetByEmployeeAsync(string employeeEmail, CancellationToken ct = default)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var records = await context.Reservations
.Where(r => r.EmployeeEmail.ToLower() == employeeEmail.ToLower() && r.Date >= today)
.ToListAsync(ct); .ToListAsync(ct);
return records.Select(ReservationMapper.ToDomain).ToList();
}
public async Task<IReadOnlyList<Reservation>> GetByWorkplaceAndDateAsync( public async Task<IReadOnlyList<Reservation>> GetByWorkplaceAndDateAsync(
Guid workplaceId, DateOnly date, CancellationToken ct = default) Guid workplaceId, DateOnly date, CancellationToken ct = default) =>
{ await context.Reservations
var records = await context.Reservations
.Where(r => r.WorkplaceId == workplaceId && r.Date == date) .Where(r => r.WorkplaceId == workplaceId && r.Date == date)
.ToListAsync(ct); .ToListAsync(ct);
return records.Select(ReservationMapper.ToDomain).ToList();
}
public Task<bool> ExistsActiveForEmployeeOnDateAsync( public Task<bool> ExistsActiveForEmployeeOnDateAsync(
string employeeEmail, DateOnly date, CancellationToken ct = default) => string employeeEmail, DateOnly date, CancellationToken ct = default) =>
@@ -53,24 +36,14 @@ public class ReservationRepository(AppDbContext context) : IReservationRepositor
ct); ct);
public async Task<IReadOnlyList<Reservation>> GetActiveReservationsForDateAsync( public async Task<IReadOnlyList<Reservation>> GetActiveReservationsForDateAsync(
DateOnly date, CancellationToken ct = default) DateOnly date, CancellationToken ct = default) =>
{ await context.Reservations
var records = await context.Reservations
.Where(r => r.Date == date && r.Status == ReservationStatus.Active) .Where(r => r.Date == date && r.Status == ReservationStatus.Active)
.ToListAsync(ct); .ToListAsync(ct);
return records.Select(ReservationMapper.ToDomain).ToList();
}
public async Task AddAsync(Reservation reservation, CancellationToken ct = default) public async Task AddAsync(Reservation reservation, CancellationToken ct = default) =>
{ await context.Reservations.AddAsync(reservation, ct);
var record = ReservationMapper.ToRecord(reservation);
await context.Reservations.AddAsync(record, ct);
}
public Task SaveChangesAsync(CancellationToken ct = default) public Task SaveChangesAsync(CancellationToken ct = default) =>
{ context.SaveChangesAsync(ct);
foreach (var (domain, record) in _tracked.Values)
ReservationMapper.SyncToRecord(domain, record);
return context.SaveChangesAsync(ct);
}
} }

View File

@@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore;
using Randall.Application.Common; using Randall.Application.Common;
using Randall.Domain.Users; using Randall.Domain.Users;
using Randall.Domain.Workplaces; using Randall.Domain.Workplaces;
using Randall.Infrastructure.Persistence.Mappers;
namespace Randall.Infrastructure.Persistence.Seeding; namespace Randall.Infrastructure.Persistence.Seeding;
@@ -26,7 +25,7 @@ public static class DatabaseSeeder
// Seed workplaces only if none exist yet // Seed workplaces only if none exist yet
if (!await context.Workplaces.AnyAsync()) if (!await context.Workplaces.AnyAsync())
{ {
var workplaces = ExpectedWorkplaces.Select(w => WorkplaceMapper.ToRecord(new Workplace(w.Name, w.Location))); var workplaces = ExpectedWorkplaces.Select(w => new Workplace(w.Name, w.Location));
context.Workplaces.AddRange(workplaces); context.Workplaces.AddRange(workplaces);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }
@@ -36,7 +35,7 @@ public static class DatabaseSeeder
{ {
var hash = passwordHasher.Hash(AdminPassword); var hash = passwordHasher.Hash(AdminPassword);
var admin = User.Create(AdminEmail, "Admin", hash, isAdmin: true).Value!; var admin = User.Create(AdminEmail, "Admin", hash, isAdmin: true).Value!;
context.Users.Add(UserMapper.ToRecord(admin)); context.Users.Add(admin);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }
} }

View File

@@ -1,73 +1,37 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Randall.Domain.Users; using Randall.Domain.Users;
using Randall.Infrastructure.Persistence.Mappers;
using Randall.Infrastructure.Persistence.Records;
namespace Randall.Infrastructure.Persistence.Users; namespace Randall.Infrastructure.Persistence.Users;
public class UserRepository(AppDbContext context) : IUserRepository public class UserRepository(AppDbContext context) : IUserRepository
{ {
private readonly Dictionary<Guid, (User Domain, UserRecord Record)> _tracked = []; public Task<User?> GetByEmailAsync(string email, CancellationToken ct = default) =>
context.Users.FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), ct);
private User Track(UserRecord record) public Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
{ context.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
var domain = UserMapper.ToDomain(record);
_tracked[domain.Id] = (domain, record);
return domain;
}
public async Task<User?> GetByEmailAsync(string email, CancellationToken ct = default)
{
var record = await context.Users.FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), ct);
return record is null ? null : Track(record);
}
public async Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
var record = await context.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
return record is null ? null : Track(record);
}
public Task<bool> ExistsByEmailAsync(string email, CancellationToken ct = default) => public Task<bool> ExistsByEmailAsync(string email, CancellationToken ct = default) =>
context.Users.AnyAsync(u => u.Email == email.ToLowerInvariant(), ct); context.Users.AnyAsync(u => u.Email == email.ToLowerInvariant(), ct);
public async Task<List<User>> GetPendingAsync(CancellationToken ct = default) public Task<List<User>> GetPendingAsync(CancellationToken ct = default) =>
{ context.Users.Where(u => !u.IsApproved && !u.IsAdmin).ToListAsync(ct);
var records = await context.Users.Where(u => !u.IsApproved && !u.IsAdmin).ToListAsync(ct);
return records.Select(UserMapper.ToDomain).ToList();
}
public async Task<List<User>> GetAllAsync(CancellationToken ct = default) public Task<List<User>> GetAllAsync(CancellationToken ct = default) =>
{ context.Users.OrderBy(u => u.Name).ToListAsync(ct);
var records = await context.Users.OrderBy(u => u.Name).ToListAsync(ct);
return records.Select(UserMapper.ToDomain).ToList();
}
public Task<int> CountAdminsAsync(CancellationToken ct = default) => public Task<int> CountAdminsAsync(CancellationToken ct = default) =>
context.Users.CountAsync(u => u.IsAdmin); context.Users.CountAsync(u => u.IsAdmin);
public async Task<List<User>> GetAllNonAdminAsync(CancellationToken ct = default) public Task<List<User>> GetAllNonAdminAsync(CancellationToken ct = default) =>
{ context.Users.Where(u => !u.IsAdmin).OrderBy(u => u.Name).ToListAsync(ct);
var records = await context.Users.Where(u => !u.IsAdmin).OrderBy(u => u.Name).ToListAsync(ct);
return records.Select(UserMapper.ToDomain).ToList();
}
public async Task AddAsync(User user, CancellationToken ct = default) public async Task AddAsync(User user, CancellationToken ct = default) =>
{ await context.Users.AddAsync(user, ct);
var record = UserMapper.ToRecord(user);
await context.Users.AddAsync(record, ct);
}
public void Delete(User user) public void Delete(User user) =>
{ context.Users.Remove(user);
if (_tracked.TryGetValue(user.Id, out var entry))
context.Users.Remove(entry.Record);
}
public Task SaveChangesAsync(CancellationToken ct = default) public Task SaveChangesAsync(CancellationToken ct = default) =>
{ context.SaveChangesAsync(ct);
foreach (var (domain, record) in _tracked.Values)
UserMapper.SyncToRecord(domain, record);
return context.SaveChangesAsync(ct);
}
} }

View File

@@ -1,20 +1,13 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Randall.Domain.Workplaces; using Randall.Domain.Workplaces;
using Randall.Infrastructure.Persistence.Mappers;
namespace Randall.Infrastructure.Persistence.Workplaces; namespace Randall.Infrastructure.Persistence.Workplaces;
public class WorkplaceRepository(AppDbContext context) : IWorkplaceRepository public class WorkplaceRepository(AppDbContext context) : IWorkplaceRepository
{ {
public async Task<Workplace?> GetByIdAsync(Guid id, CancellationToken ct = default) public Task<Workplace?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
{ context.Workplaces.FirstOrDefaultAsync(w => w.Id == id, ct);
var record = await context.Workplaces.FirstOrDefaultAsync(w => w.Id == id, ct);
return record is null ? null : WorkplaceMapper.ToDomain(record);
}
public async Task<IReadOnlyList<Workplace>> GetAllActiveAsync(CancellationToken ct = default) public async Task<IReadOnlyList<Workplace>> GetAllActiveAsync(CancellationToken ct = default) =>
{ await context.Workplaces.Where(w => w.IsActive).ToListAsync(ct);
var records = await context.Workplaces.Where(w => w.IsActive).ToListAsync(ct);
return records.Select(WorkplaceMapper.ToDomain).ToList();
}
} }

View File

@@ -146,85 +146,4 @@ public class AdminTests(CustomWebApplicationFactory factory) : IClassFixture<Cus
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
} }
[Fact]
public async Task AddUser_WithoutAuth_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/admin/users",
new { Email = "new@test.com", Name = "New User", Password = "Test@1234" });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task AddUser_WithNonAdmin_Returns403()
{
var email = $"{Guid.NewGuid():N}@test.com";
var token = await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email);
var client = factory.CreateClient();
AuthHelper.SetBearerToken(client, token);
var response = await client.PostAsJsonAsync("/api/admin/users",
new { Email = $"{Guid.NewGuid():N}@test.com", Name = "New User", Password = "Test@1234" });
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task AddUser_WithAdmin_Returns201WithUserDetails()
{
var client = factory.CreateClient();
var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
AuthHelper.SetBearerToken(client, adminLogin.Token);
var email = $"{Guid.NewGuid():N}@test.com";
var response = await client.PostAsJsonAsync("/api/admin/users",
new { Email = email, Name = "New User", Password = "Test@1234" });
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var user = await response.Content.ReadFromJsonAsync<AdminUserResponse>(AuthHelper.JsonOptions);
Assert.NotNull(user);
Assert.Equal(email, user.Email);
Assert.Equal("New User", user.Name);
Assert.True(user.IsApproved);
Assert.False(user.IsAdmin);
}
[Fact]
public async Task AddUser_DuplicateEmail_ReturnsBadRequest()
{
var client = factory.CreateClient();
var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
AuthHelper.SetBearerToken(client, adminLogin.Token);
var email = $"{Guid.NewGuid():N}@test.com";
await client.PostAsJsonAsync("/api/admin/users",
new { Email = email, Name = "First User", Password = "Test@1234" });
var response = await client.PostAsJsonAsync("/api/admin/users",
new { Email = email, Name = "Duplicate User", Password = "Test@1234" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task AddUser_CreatedUserCanLoginImmediately()
{
var adminClient = factory.CreateClient();
var adminLogin = await AuthHelper.LoginAsAdminAsync(adminClient);
AuthHelper.SetBearerToken(adminClient, adminLogin.Token);
var email = $"{Guid.NewGuid():N}@test.com";
const string password = "Test@1234";
await adminClient.PostAsJsonAsync("/api/admin/users",
new { Email = email, Name = "Direct User", Password = password });
var loginClient = factory.CreateClient();
var login = await AuthHelper.LoginAsync(loginClient, email, password);
Assert.NotEmpty(login.Token);
}
} }

View File

@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace Randall.Api.IntegrationTests; namespace Randall.Api.IntegrationTests;
@@ -29,9 +28,6 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{ {
base.Dispose(disposing); base.Dispose(disposing);
if (disposing && File.Exists(_dbPath)) if (disposing && File.Exists(_dbPath))
{
SqliteConnection.ClearAllPools();
File.Delete(_dbPath); File.Delete(_dbPath);
} }
} }
}

View File

@@ -1,61 +0,0 @@
using Randall.Domain.Reservations;
namespace Randall.Domain.UnitTests.Reservations;
public class ReservationFactoryTests
{
private static readonly Guid Id = Guid.NewGuid();
private static readonly Guid WorkplaceId = Guid.NewGuid();
private const string Email = "jane@company.com";
private const string Name = "Jane Smith";
private static readonly DateOnly Date = new(2026, 6, 1);
private static readonly DateTime CreatedAt = new(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void Reconstitute_MapsAllFieldsExactly()
{
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, Date, ReservationStatus.Active, CreatedAt);
Assert.Equal(Id, reservation.Id);
Assert.Equal(WorkplaceId, reservation.WorkplaceId);
Assert.Equal(Email, reservation.EmployeeEmail);
Assert.Equal(Name, reservation.EmployeeName);
Assert.Equal(Date, reservation.Date);
Assert.Equal(ReservationStatus.Active, reservation.Status);
Assert.Equal(CreatedAt, reservation.CreatedAt);
}
[Fact]
public void Reconstitute_PreservesProvidedId()
{
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, Date, ReservationStatus.Active, CreatedAt);
Assert.Equal(Id, reservation.Id);
}
[Fact]
public void Reconstitute_CanRestoreActiveReservation()
{
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, Date, ReservationStatus.Active, CreatedAt);
Assert.Equal(ReservationStatus.Active, reservation.Status);
}
[Fact]
public void Reconstitute_CanRestoreCancelledReservation()
{
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, Date, ReservationStatus.Cancelled, CreatedAt);
Assert.Equal(ReservationStatus.Cancelled, reservation.Status);
}
[Fact]
public void Reconstitute_CanRestorePastReservation()
{
var pastDate = new DateOnly(2020, 1, 1);
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, pastDate, ReservationStatus.Active, CreatedAt);
Assert.Equal(pastDate, reservation.Date);
}
}

View File

@@ -1,58 +0,0 @@
using Randall.Domain.Users;
namespace Randall.Domain.UnitTests.Users;
public class UserFactoryTests
{
private static readonly Guid Id = Guid.NewGuid();
private const string Email = "jane@company.com";
private const string Name = "Jane Smith";
private const string PasswordHash = "hashed-password";
[Fact]
public void Reconstitute_MapsAllFieldsExactly()
{
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: true, isAdmin: false);
Assert.Equal(Id, user.Id);
Assert.Equal(Email, user.Email);
Assert.Equal(Name, user.Name);
Assert.Equal(PasswordHash, user.PasswordHash);
Assert.True(user.IsApproved);
Assert.False(user.IsAdmin);
}
[Fact]
public void Reconstitute_PreservesProvidedId()
{
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: false, isAdmin: false);
Assert.Equal(Id, user.Id);
}
[Fact]
public void Reconstitute_CanRestoreApprovedNonAdminUser()
{
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: true, isAdmin: false);
Assert.True(user.IsApproved);
Assert.False(user.IsAdmin);
}
[Fact]
public void Reconstitute_CanRestoreUnapprovedUser()
{
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: false, isAdmin: false);
Assert.False(user.IsApproved);
}
[Fact]
public void Reconstitute_CanRestoreAdminUser()
{
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: true, isAdmin: true);
Assert.True(user.IsAdmin);
Assert.True(user.IsApproved);
}
}

View File

@@ -1,45 +0,0 @@
using Randall.Domain.Workplaces;
namespace Randall.Domain.UnitTests.Workplaces;
public class WorkplaceFactoryTests
{
private static readonly Guid Id = Guid.NewGuid();
private const string Name = "D13";
private const string Location = "Pod A";
[Fact]
public void Reconstitute_MapsAllFieldsExactly()
{
var workplace = WorkplaceFactory.Reconstitute(Id, Name, Location, isActive: true);
Assert.Equal(Id, workplace.Id);
Assert.Equal(Name, workplace.Name);
Assert.Equal(Location, workplace.Location);
Assert.True(workplace.IsActive);
}
[Fact]
public void Reconstitute_PreservesProvidedId()
{
var workplace = WorkplaceFactory.Reconstitute(Id, Name, Location, isActive: true);
Assert.Equal(Id, workplace.Id);
}
[Fact]
public void Reconstitute_CanRestoreActiveWorkplace()
{
var workplace = WorkplaceFactory.Reconstitute(Id, Name, Location, isActive: true);
Assert.True(workplace.IsActive);
}
[Fact]
public void Reconstitute_CanRestoreInactiveWorkplace()
{
var workplace = WorkplaceFactory.Reconstitute(Id, Name, Location, isActive: false);
Assert.False(workplace.IsActive);
}
}

View File

@@ -4,10 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>randall</title> <title>frontend</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Rubik+Mono+One&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -40,7 +40,7 @@ export default function App() {
<Route path="/" element={<PlannerPage auth={auth} onLogout={handleLogout} />} /> <Route path="/" element={<PlannerPage auth={auth} onLogout={handleLogout} />} />
<Route <Route
path="/admin" path="/admin"
element={auth.isAdmin ? <AdminPage onLogout={handleLogout} /> : <Navigate to="/" replace />} element={auth.isAdmin ? <AdminPage /> : <Navigate to="/" replace />}
/> />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View File

@@ -1,4 +1,4 @@
import type { AddUserRequest, AdminUser, AuthResponse, CreateReservationRequest, PendingUser, RegisterPendingResponse, Reservation, WorkplaceScheduleItem } from './types'; import type { AdminUser, AuthResponse, CreateReservationRequest, PendingUser, RegisterPendingResponse, Reservation, WorkplaceScheduleItem } from './types';
const BASE = '/api'; const BASE = '/api';
@@ -38,15 +38,6 @@ export const api = {
return handleResponse<RegisterPendingResponse>(res); return handleResponse<RegisterPendingResponse>(res);
}, },
async addUser(data: AddUserRequest): Promise<AdminUser> {
const res = await fetch(`${BASE}/admin/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(data),
});
return handleResponse<AdminUser>(res);
},
getAdminUsers(): Promise<AdminUser[]> { getAdminUsers(): Promise<AdminUser[]> {
return fetch(`${BASE}/admin/users`, { return fetch(`${BASE}/admin/users`, {
headers: authHeaders(), headers: authHeaders(),

View File

@@ -59,9 +59,3 @@ export interface AdminUser {
isApproved: boolean; isApproved: boolean;
isAdmin: boolean; isAdmin: boolean;
} }
export interface AddUserRequest {
email: string;
name: string;
password: string;
}

View File

@@ -7,12 +7,6 @@ interface CancelModalProps {
onClose: () => void; onClose: () => void;
} }
const PURPLE = '#5b4fc7';
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
const PAPER = '#f4f3ee';
export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalProps) { export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -30,77 +24,30 @@ export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalP
} }
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div <div
style={{ className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md mx-4"
position: 'fixed', inset: 0,
background: 'rgba(42,31,107,0.35)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 50,
}}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
style={{
background: PAPER, borderRadius: 18, padding: 32,
width: '100%', maxWidth: 380, margin: '0 16px',
border: '1px solid rgba(91,79,199,0.12)',
boxShadow: '0 20px 60px -12px rgba(42,31,107,0.25)',
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div style={{ <h2 className="text-xl font-semibold text-slate-800 mb-1">Cancel reservation</h2>
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase', <p className="text-sm text-slate-500 mb-6">
color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500, Cancel your booking for <span className="font-medium text-slate-700">{deskName}</span> on{' '}
}}> <span className="font-medium text-slate-700">{date}</span>?
Cancel reservation </p>
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 36, letterSpacing: '-0.03em', color: PURPLE,
lineHeight: 0.95, marginBottom: 6,
}}>
{deskName}
</div>
<div style={{ fontSize: 13, color: PURPLE, opacity: 0.65, marginBottom: 24 }}>
{date}
</div>
{error && ( {error && <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg mb-4">{error}</p>}
<div style={{
fontSize: 13, color: '#c0392b',
background: 'rgba(192,57,43,0.08)',
borderRadius: 8, padding: '10px 14px',
marginBottom: 18,
}}>
{error}
</div>
)}
<div style={{ display: 'flex', gap: 10 }}> <div className="flex gap-3">
<button <button
onClick={onClose} onClick={onClose}
style={{ className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors"
flex: 1, padding: '11px 16px', borderRadius: 99,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
> >
Keep it Keep it
</button> </button>
<button <button
onClick={handleConfirm} onClick={handleConfirm}
disabled={loading} disabled={loading}
style={{ className="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-500 rounded-lg hover:bg-red-600 disabled:opacity-50 transition-colors"
flex: 1, padding: '11px 16px', borderRadius: 99,
background: PURPLE_DEEP, border: `1.5px solid rgba(42,31,107,0.8)`,
color: '#fff', fontSize: 13, fontWeight: 500,
cursor: loading ? 'default' : 'pointer',
fontFamily: 'inherit',
opacity: loading ? 0.6 : 1,
}}
> >
{loading ? 'Cancelling…' : 'Cancel reservation'} {loading ? 'Cancelling…' : 'Cancel reservation'}
</button> </button>

View File

@@ -1,104 +1,72 @@
import { useState } from 'react';
interface DeskProps { interface DeskProps {
name: string; name: string;
available: boolean; available: boolean;
reserved: boolean; reserved: boolean; // reserved by the current user
reservedBy?: string; reservedBy?: string; // name of whoever reserved it (when taken by someone else)
rotate?: 'cw' | 'ccw'; rotate?: 'cw' | 'ccw';
onClick: () => void; onClick: () => void;
} }
const PURPLE = '#5b4fc7';
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
const PAPER = '#f4f3ee';
export function Desk({ name, available, reserved, reservedBy, rotate, onClick }: DeskProps) { export function Desk({ name, available, reserved, reservedBy, rotate, onClick }: DeskProps) {
const [hover, setHover] = useState(false); let bgColor: string;
let borderColor: string;
let textColor: string;
let deskColor: string;
let cursor: string;
let title: string;
const isMine = reserved; if (reserved) {
const isFree = !reserved && available; bgColor = 'bg-blue-50';
const isTaken = !reserved && !available; borderColor = 'border-blue-400';
textColor = 'text-blue-700';
deskColor = '#93c5fd';
cursor = 'cursor-pointer hover:bg-blue-100';
title = 'Your reservation — click to cancel';
} else if (available) {
bgColor = 'bg-emerald-50';
borderColor = 'border-emerald-400';
textColor = 'text-emerald-700';
deskColor = '#6ee7b7';
cursor = 'cursor-pointer hover:bg-emerald-100 hover:scale-105';
title = `Reserve ${name}`;
} else {
bgColor = 'bg-slate-50';
borderColor = 'border-slate-300';
textColor = 'text-slate-500';
deskColor = '#cbd5e1';
cursor = 'cursor-default';
title = reservedBy ? `Reserved by ${reservedBy}` : `${name} is taken`;
}
const svgFill = isMine ? PURPLE_DEEP : isFree ? PURPLE : 'rgba(91,79,199,0.25)'; // Truncate long names to fit the tile
const displayName = reservedBy && reservedBy.length > 7
const displayLabel = isFree
? 'free'
: isMine
? 'yours'
: reservedBy
? reservedBy.length > 7
? reservedBy.slice(0, 6) + '…' ? reservedBy.slice(0, 6) + '…'
: reservedBy : reservedBy;
: 'taken';
return ( return (
<button <button
onMouseEnter={() => setHover(true)} className={`flex flex-col items-center justify-center w-20 rounded-xl border-2 font-semibold transition-all select-none py-2 px-1 gap-0.5 ${bgColor} ${borderColor} ${textColor} ${cursor}`}
onMouseLeave={() => setHover(false)} onClick={available || reserved ? onClick : undefined}
onClick={isTaken ? undefined : onClick} title={title}
title={
isMine
? `${name} — your reservation (click to cancel)`
: isFree
? `Reserve ${name}`
: reservedBy
? `Reserved by ${reservedBy}`
: `${name} is taken`
}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: 74,
height: 80,
padding: '4px',
gap: 1,
borderRadius: 12,
background: isMine ? SAGE : isFree ? PAPER : 'transparent',
border: isMine
? `1.5px solid ${SAGE_DEEP}`
: isFree
? '1px solid rgba(91,79,199,0.18)'
: '1px dashed rgba(91,79,199,0.18)',
color: isMine ? PURPLE_DEEP : isFree ? PURPLE : 'rgba(91,79,199,0.45)',
cursor: isTaken ? 'default' : 'pointer',
boxShadow: isMine
? '0 6px 16px -8px rgba(91,79,199,0.25)'
: hover && isFree
? '0 8px 20px -10px rgba(91,79,199,0.20)'
: 'none',
transform: hover && !isTaken ? 'translateY(-2px)' : 'translateY(0)',
transition: 'transform 220ms cubic-bezier(0.34,1.56,0.64,1), box-shadow 200ms, border-color 160ms',
fontFamily: 'inherit',
outline: 'none',
}}
> >
<svg {/* Top-down desk: rectangular surface + screen stripe */}
viewBox="0 0 48 48" <svg viewBox="0 0 48 48" className={`w-10 h-10 ${rotate === 'cw' ? 'rotate-90' : rotate === 'ccw' ? '-rotate-90' : ''}`} fill="none" aria-hidden="true">
width={28} {/* Desk surface (wide rectangle, ~1.6:1 ratio) */}
height={28} <rect x="2" y="10" width="44" height="28" rx="2.5" fill={deskColor} />
fill="none" {/* Screen stripe — centered, ~30% of desk width, small margin from back edge */}
aria-hidden="true" <rect x="17" y="13" width="14" height="5" rx="1.5" fill="#1e293b" />
style={{ {/* Chair — small square in front of desk */}
transform: rotate === 'cw' ? 'rotate(90deg)' : 'rotate(-90deg)', <rect x="18" y="41" width="12" height="8" rx="2" fill={deskColor} />
transition: 'transform 200ms',
flexShrink: 0,
}}
>
<rect x="2" y="10" width="44" height="28" rx="2.5" fill={svgFill} />
<rect x="17" y="13" width="14" height="5" rx="1.5" fill={PURPLE_DEEP} opacity="0.7" />
<rect x="18" y="41" width="12" height="8" rx="2" fill={svgFill} />
</svg> </svg>
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: '-0.01em', lineHeight: 1 }}>
{name} <span className="text-xs font-semibold leading-none">{name}</span>
</span> {reserved && <span className="text-[10px] opacity-75 leading-none">Mine</span>}
<span style={{ fontSize: 9, opacity: 0.7, fontWeight: 500, lineHeight: 1.1, textAlign: 'center' }}> {available && <span className="text-[10px] opacity-75 leading-none">Free</span>}
{displayLabel} {!reserved && !available && (
<span className="text-[10px] opacity-75 leading-tight text-center px-0.5">
{displayName ?? 'Taken'}
</span> </span>
)}
</button> </button>
); );
} }

View File

@@ -9,39 +9,20 @@ interface ScheduleItem {
} }
interface DeskPodProps { interface DeskPodProps {
label: string;
desks: ScheduleItem[]; desks: ScheduleItem[];
myReservedIds: Set<string>; myReservedIds: Set<string>;
onDeskClick: (desk: ScheduleItem) => void; onDeskClick: (desk: ScheduleItem) => void;
} }
export function DeskPod({ label, desks, myReservedIds, onDeskClick }: DeskPodProps) { export function DeskPod({ desks, myReservedIds, onDeskClick }: DeskPodProps) {
const left = [...desks.slice(0, 4)].reverse(); const left = [...desks.slice(0, 4)].reverse();
const right = [...desks.slice(4, 8)].reverse(); const right = [...desks.slice(4, 8)].reverse();
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, flex: '0 0 auto' }}> <div className="flex flex-col items-center gap-2">
<div style={{ <div className="bg-white border-2 border-slate-200 rounded-2xl p-5 shadow-sm">
fontSize: 10, <div className="flex gap-6">
letterSpacing: '0.18em', <div className="flex flex-col gap-3">
textTransform: 'uppercase',
color: 'var(--purple)',
opacity: 0.6,
fontWeight: 500,
}}>
{label}
</div>
<div style={{
display: 'inline-flex',
gap: 10,
padding: '10px',
background: 'rgba(255,255,255,0.4)',
borderRadius: 14,
border: '1px solid rgba(91,79,199,0.08)',
width: 'fit-content',
alignSelf: 'flex-start',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{left.map((desk) => ( {left.map((desk) => (
<Desk <Desk
key={desk.id} key={desk.id}
@@ -54,8 +35,10 @@ export function DeskPod({ label, desks, myReservedIds, onDeskClick }: DeskPodPro
/> />
))} ))}
</div> </div>
<div style={{ width: 1, background: 'rgba(91,79,199,0.10)', alignSelf: 'stretch' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> <div className="w-px bg-slate-100" />
<div className="flex flex-col gap-3">
{right.map((desk) => ( {right.map((desk) => (
<Desk <Desk
key={desk.id} key={desk.id}
@@ -70,5 +53,6 @@ export function DeskPod({ label, desks, myReservedIds, onDeskClick }: DeskPodPro
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -5,11 +5,6 @@ interface MyReservationsProps {
onCancel: (reservation: Reservation) => void; onCancel: (reservation: Reservation) => void;
} }
const PURPLE = '#5b4fc7';
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
export function MyReservations({ reservations, onCancel }: MyReservationsProps) { export function MyReservations({ reservations, onCancel }: MyReservationsProps) {
const upcoming = reservations const upcoming = reservations
.filter((r) => r.status === 'Active') .filter((r) => r.status === 'Active')
@@ -17,46 +12,24 @@ export function MyReservations({ reservations, onCancel }: MyReservationsProps)
if (upcoming.length === 0) { if (upcoming.length === 0) {
return ( return (
<p style={{ fontSize: 13, color: PURPLE, opacity: 0.4, textAlign: 'center', padding: '8px 0', margin: 0 }}> <p className="text-sm text-slate-400 text-center py-4">No upcoming reservations.</p>
No upcoming reservations.
</p>
); );
} }
return ( return (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}> <ul className="flex flex-col gap-2">
{upcoming.map((r) => ( {upcoming.map((r) => (
<li <li
key={r.id} key={r.id}
style={{ className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-4 py-3"
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 0',
borderBottom: '1px solid rgba(91,79,199,0.08)',
}}
> >
<div> <div>
<div style={{ fontSize: 13, fontWeight: 600, color: PURPLE, lineHeight: 1 }}> <span className="font-medium text-slate-800">{r.workplaceName}</span>
{r.workplaceName} <div className="text-xs text-slate-400 mt-0.5">{r.date}</div>
</div>
<div style={{ fontSize: 11, color: PURPLE, opacity: 0.55, marginTop: 3 }}>
{r.date}
</div>
</div> </div>
<button <button
onClick={() => onCancel(r)} onClick={() => onCancel(r)}
style={{ className="text-xs text-red-500 hover:text-red-700 font-medium px-3 py-1 rounded-lg hover:bg-red-50 transition-colors"
padding: '5px 12px',
borderRadius: 99,
background: SAGE,
border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP,
fontSize: 11,
fontWeight: 500,
cursor: 'pointer',
fontFamily: 'inherit',
}}
> >
Cancel Cancel
</button> </button>

View File

@@ -7,12 +7,6 @@ interface ReservationModalProps {
onClose: () => void; onClose: () => void;
} }
const PURPLE = '#5b4fc7';
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
const PAPER = '#f4f3ee';
export function ReservationModal({ deskName, date, onConfirm, onClose }: ReservationModalProps) { export function ReservationModal({ deskName, date, onConfirm, onClose }: ReservationModalProps) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -30,77 +24,29 @@ export function ReservationModal({ deskName, date, onConfirm, onClose }: Reserva
} }
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div <div
style={{ className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-sm mx-4"
position: 'fixed', inset: 0,
background: 'rgba(42,31,107,0.35)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 50,
}}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
style={{
background: PAPER, borderRadius: 18, padding: 32,
width: '100%', maxWidth: 360, margin: '0 16px',
border: '1px solid rgba(91,79,199,0.12)',
boxShadow: '0 20px 60px -12px rgba(42,31,107,0.25)',
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div style={{ <h2 className="text-xl font-semibold text-slate-800 mb-1">Reserve desk</h2>
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase', <p className="text-sm text-slate-500 mb-6">
color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500, <span className="font-medium text-slate-700">{deskName}</span> &mdash; {date}
}}> </p>
Reserve desk
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 36, letterSpacing: '-0.03em', color: PURPLE,
lineHeight: 0.95, marginBottom: 6,
}}>
{deskName}
</div>
<div style={{ fontSize: 13, color: PURPLE, opacity: 0.65, marginBottom: 24 }}>
{date}
</div>
{error && ( {error && <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg mb-4">{error}</p>}
<div style={{
fontSize: 13, color: '#c0392b',
background: 'rgba(192,57,43,0.08)',
borderRadius: 8, padding: '10px 14px',
marginBottom: 18,
}}>
{error}
</div>
)}
<div style={{ display: 'flex', gap: 10 }}> <div className="flex gap-3">
<button <button
onClick={onClose} onClick={onClose}
style={{ className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors"
flex: 1, padding: '11px 16px', borderRadius: 99,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
> >
Cancel Cancel
</button> </button>
<button <button
onClick={handleConfirm} onClick={handleConfirm}
disabled={loading} disabled={loading}
style={{ className="flex-1 px-4 py-2 text-sm font-medium text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors"
flex: 1, padding: '11px 16px', borderRadius: 99,
background: PURPLE, border: `1.5px solid ${PURPLE_DEEP}`,
color: '#fff', fontSize: 13, fontWeight: 500,
cursor: loading ? 'default' : 'pointer',
fontFamily: 'inherit',
opacity: loading ? 0.6 : 1,
}}
> >
{loading ? 'Reserving…' : 'Confirm'} {loading ? 'Reserving…' : 'Confirm'}
</button> </button>

View File

@@ -1,27 +1,12 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
--bg: #e6e7e0;
--paper: #f4f3ee;
--ink: #2a1f6b;
--purple: #5b4fc7;
--purple-deep: #3f33a8;
--sage: #c7d4b8;
--sage-deep: #a9bb96;
}
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
margin: 0; margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg); background-color: #f8fafc;
color: var(--ink); color: #0f172a;
}
button:focus-visible {
outline: 2px solid var(--purple);
outline-offset: 2px;
} }

View File

@@ -3,35 +3,16 @@ import { useNavigate } from 'react-router-dom';
import { api } from '../api/client'; import { api } from '../api/client';
import type { AdminUser } from '../api/types'; import type { AdminUser } from '../api/types';
const PURPLE = '#5b4fc7'; export function AdminPage() {
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
const PAPER = '#f4f3ee';
interface AdminPageProps {
onLogout: () => void;
}
function monogram(name: string): string {
return name.split(' ').map((n) => n[0]).join('').slice(0, 2).toUpperCase();
}
export function AdminPage({ onLogout }: AdminPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [approvingId, setApprovingId] = useState<string | null>(null); const [approvingId, setApprovingId] = useState<string | null>(null);
const [makingAdminId, setMakingAdminId] = useState<string | null>(null); const [makingAdminId, setMakingAdminId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmAdminId, setConfirmAdminId] = useState<string | null>(null); const [confirmAdminId, setConfirmAdminId] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [addForm, setAddForm] = useState({ name: '', email: '', password: '' });
const [addError, setAddError] = useState('');
const [addSubmitting, setAddSubmitting] = useState(false);
async function load() { async function load() {
setLoading(true); setLoading(true);
@@ -63,7 +44,7 @@ export function AdminPage({ onLogout }: AdminPageProps) {
setMakingAdminId(id); setMakingAdminId(id);
try { try {
await api.makeAdmin(id); await api.makeAdmin(id);
setUsers((prev) => prev.map((u) => u.id === id ? { ...u, isAdmin: true } : u)); setUsers((prev) => prev.filter((u) => u.id !== id));
setConfirmAdminId(null); setConfirmAdminId(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to make user admin'); setError(err instanceof Error ? err.message : 'Failed to make user admin');
@@ -85,472 +66,147 @@ export function AdminPage({ onLogout }: AdminPageProps) {
} }
} }
async function handleAddUser(e: React.FormEvent) { const pending = users.filter((u) => !u.isApproved);
e.preventDefault(); const approved = users.filter((u) => u.isApproved);
setAddSubmitting(true);
setAddError('');
try {
const newUser = await api.addUser(addForm);
setUsers((prev) => [...prev, newUser]);
setShowAddModal(false);
setAddForm({ name: '', email: '', password: '' });
} catch (err) {
setAddError(err instanceof Error ? err.message : 'Failed to add user');
} finally {
setAddSubmitting(false);
}
}
const filtered = users.filter((u) => { function UserRow({ user }: { user: AdminUser }) {
const q = search.toLowerCase(); const busy = approvingId === user.id || makingAdminId === user.id || deletingId === user.id;
return !q || u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q);
});
const totalUsers = users.length;
const approvedCount = users.filter((u) => u.isApproved).length;
const adminCount = users.filter((u) => u.isAdmin).length;
const pendingCount = users.filter((u) => !u.isApproved).length;
const kpis = [
{ label: 'Total users', value: String(totalUsers).padStart(2, '0') },
{ label: 'Approved', value: String(approvedCount).padStart(2, '0') },
{ label: 'Admins', value: String(adminCount).padStart(2, '0') },
{ label: 'Pending', value: String(pendingCount).padStart(2, '0') },
];
const TABLE_COLS = '2fr 2fr 1fr 1fr auto';
return ( return (
<div style={{ background: 'var(--bg)', minHeight: '100vh' }}> <li className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-5 py-4 shadow-sm">
<div style={{ <div>
maxWidth: 1100, <div className="flex items-center gap-2">
margin: '0 auto', <p className="font-medium text-slate-800">{user.name}</p>
height: '100vh', {user.isAdmin && (
display: 'flex', <span className="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 text-amber-700">
flexDirection: 'column',
overflow: 'hidden',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
}}>
{/* Header */}
<header style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '22px 36px', flexShrink: 0,
}}>
{/* Wordmark + Admin badge */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div
onClick={() => navigate('/')}
style={{
display: 'flex', alignItems: 'center', gap: 1,
color: PURPLE,
fontFamily: "'Rubik Mono One', monospace",
fontSize: 18, letterSpacing: '-0.02em', cursor: 'pointer',
}}
>
<span style={{ opacity: 0.6 }}>{'{'}</span>
<span style={{ padding: '0 4px' }}>randall</span>
<span style={{ opacity: 0.6 }}>{'}'}</span>
</div>
<span style={{
fontSize: 11, letterSpacing: '0.16em', textTransform: 'uppercase',
color: PURPLE, padding: '3px 9px', borderRadius: 99,
border: '1px solid rgba(91,79,199,0.25)', fontWeight: 500,
}}>
Admin Admin
</span> </span>
)}
</div> </div>
<p className="text-sm text-slate-500">{user.email}</p>
{/* Nav */} </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 28, fontSize: 13, color: PURPLE }}> <div className="flex gap-2 items-center">
<span style={{ fontWeight: 500 }}>Users</span> {!user.isApproved && (
<button <button
onClick={onLogout} onClick={() => handleApprove(user.id)}
style={{ disabled={busy || confirmAdminId === user.id || confirmDeleteId === user.id}
padding: '7px 18px', borderRadius: 99, className="text-sm font-medium px-4 py-1.5 rounded-lg bg-emerald-500 text-white hover:bg-emerald-600 disabled:opacity-50 transition-colors"
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
> >
Sign out {approvingId === user.id ? '…' : 'Approve'}
</button> </button>
</div>
</header>
{/* Body */}
<div style={{
padding: '8px 36px 32px',
display: 'flex', flexDirection: 'column', gap: 18,
minHeight: 0, flex: 1, overflow: 'hidden',
}}>
{/* Hero */}
<div>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 12, fontWeight: 500,
}}>
Users · The Hague HQ
</div>
<h1 style={{
margin: 0,
fontFamily: "'Rubik Mono One', monospace",
fontSize: 42, fontWeight: 400, letterSpacing: '-0.02em',
lineHeight: 0.95, color: PURPLE,
}}>
Who can <span style={{ color: SAGE_DEEP }}>book?</span>
</h1>
</div>
{/* KPI strip */}
{!loading && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12 }}>
{kpis.map((k) => (
<div key={k.label} style={{
background: PAPER, borderRadius: 14, padding: '14px 16px',
border: '1px solid rgba(91,79,199,0.10)',
}}>
<div style={{
fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, fontWeight: 500,
}}>
{k.label}
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 30, letterSpacing: '-0.02em', color: PURPLE,
marginTop: 4, lineHeight: 1,
}}>
{k.value}
</div>
</div>
))}
</div>
)} )}
{/* Users table card */} {!user.isAdmin && confirmAdminId === user.id ? (
<div style={{ <>
background: PAPER, borderRadius: 18, <span className="text-sm text-slate-500">Make admin?</span>
border: '1px solid rgba(91,79,199,0.10)',
flex: 1, display: 'flex', flexDirection: 'column',
minHeight: 0, overflow: 'hidden',
}}>
{/* Toolbar */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '14px 18px',
borderBottom: '1px solid rgba(91,79,199,0.10)',
flexShrink: 0,
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 12px', borderRadius: 99,
border: '1px solid rgba(91,79,199,0.18)',
background: 'rgba(255,255,255,0.4)',
fontSize: 13, color: PURPLE, minWidth: 260,
}}>
<span style={{ opacity: 0.6 }}></span>
<input
type="text"
placeholder="Search by name or email"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
background: 'none', border: 'none', outline: 'none',
fontSize: 13, color: PURPLE, fontFamily: 'inherit',
flex: 1, opacity: search ? 1 : 0.6,
}}
/>
</div>
<button <button
onClick={() => { setAddForm({ name: '', email: '', password: '' }); setAddError(''); setShowAddModal(true); }} onClick={() => handleMakeAdmin(user.id)}
style={{ disabled={makingAdminId === user.id}
padding: '8px 16px', borderRadius: 99, className="text-sm font-medium px-4 py-1.5 rounded-lg bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-50 transition-colors"
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
> >
+ Add user {makingAdminId === user.id ? '…' : 'Yes'}
</button> </button>
</div>
{/* Table head */}
<div style={{
display: 'grid', gridTemplateColumns: TABLE_COLS,
padding: '10px 18px',
fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, fontWeight: 500,
borderBottom: '1px solid rgba(91,79,199,0.08)',
flexShrink: 0,
}}>
<span>Name</span>
<span>Email</span>
<span>Role</span>
<span>Status</span>
<span>Actions</span>
</div>
{/* Table rows */}
<div style={{ overflow: 'auto', flex: 1 }}>
{loading && (
<p style={{ textAlign: 'center', color: PURPLE, opacity: 0.5, fontSize: 13, margin: '32px 0' }}>
Loading
</p>
)}
{error && (
<p style={{ textAlign: 'center', color: '#c0392b', fontSize: 13, margin: '24px 0' }}>
{error}
</p>
)}
{!loading && filtered.map((u, i) => {
const busy = approvingId === u.id || makingAdminId === u.id || deletingId === u.id;
const isLastRow = i === filtered.length - 1;
return (
<div key={u.id} data-testid="user-row" style={{
display: 'grid', gridTemplateColumns: TABLE_COLS,
alignItems: 'center',
padding: '12px 18px',
fontSize: 13, color: PURPLE_DEEP,
borderBottom: isLastRow ? 'none' : '1px solid rgba(91,79,199,0.06)',
}}>
{/* Name */}
<span style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{
width: 26, height: 26, borderRadius: '50%',
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, fontWeight: 600, color: PURPLE_DEEP,
flexShrink: 0,
}}>
{monogram(u.name)}
</span>
<span style={{ fontWeight: 500 }}>{u.name}</span>
</span>
{/* Email */}
<span style={{ color: PURPLE, opacity: 0.85, fontSize: 12 }}>{u.email}</span>
{/* Role badge */}
<span>
<span data-testid="role-badge" style={{
fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase',
padding: '2px 8px', borderRadius: 99, fontWeight: 500,
background: u.isAdmin ? SAGE : 'transparent',
border: `1px solid ${u.isAdmin ? SAGE_DEEP : 'rgba(91,79,199,0.20)'}`,
color: u.isAdmin ? PURPLE_DEEP : PURPLE,
}}>
{u.isAdmin ? 'admin' : 'employee'}
</span>
</span>
{/* Status */}
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: PURPLE }}>
<span style={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
background: u.isApproved ? SAGE_DEEP : 'rgba(91,79,199,0.30)',
}} />
{u.isApproved ? 'Active' : 'Pending'}
</span>
{/* Actions */}
<span style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{!u.isApproved && (
confirmAdminId !== u.id && confirmDeleteId !== u.id && (
<button <button
onClick={() => handleApprove(u.id)} onClick={() => setConfirmAdminId(null)}
disabled={busy} className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
style={{
padding: '3px 10px', borderRadius: 99, fontSize: 11, fontWeight: 500,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, cursor: busy ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: busy ? 0.5 : 1,
}}
> >
{approvingId === u.id ? '…' : 'Approve'} Cancel
</button>
</>
) : (
!user.isAdmin && (
<button
onClick={() => setConfirmAdminId(user.id)}
disabled={busy || confirmDeleteId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-50 transition-colors"
>
Make admin
</button> </button>
) )
)} )}
{confirmAdminId === u.id ? ( {confirmDeleteId === user.id ? (
<> <>
<span style={{ fontSize: 12, color: PURPLE, opacity: 0.7 }}>Make admin?</span> <span className="text-sm text-slate-500">Sure?</span>
<button <button
onClick={() => handleMakeAdmin(u.id)} onClick={() => handleDelete(user.id)}
disabled={!!makingAdminId} disabled={deletingId === user.id}
style={{ className="text-sm font-medium px-4 py-1.5 rounded-lg bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors"
padding: '3px 10px', borderRadius: 99, fontSize: 11, fontWeight: 500,
background: PURPLE, border: `1px solid ${PURPLE_DEEP}`,
color: '#fff', cursor: 'pointer', fontFamily: 'inherit',
}}
> >
{makingAdminId === u.id ? '…' : 'Yes'} {deletingId === user.id ? '…' : 'Yes, delete'}
</button>
<button
onClick={() => setConfirmAdminId(null)}
style={{
background: 'none', border: 'none', fontSize: 12,
color: PURPLE, opacity: 0.6, cursor: 'pointer', fontFamily: 'inherit',
}}
>
No
</button>
</>
) : confirmDeleteId === u.id ? (
<>
<span style={{ fontSize: 12, color: PURPLE, opacity: 0.7 }}>Delete?</span>
<button
onClick={() => handleDelete(u.id)}
disabled={!!deletingId}
style={{
padding: '3px 10px', borderRadius: 99, fontSize: 11, fontWeight: 500,
background: PURPLE_DEEP, border: `1px solid ${PURPLE_DEEP}`,
color: '#fff', cursor: 'pointer', fontFamily: 'inherit',
}}
>
{deletingId === u.id ? '…' : 'Yes'}
</button> </button>
<button <button
onClick={() => setConfirmDeleteId(null)} onClick={() => setConfirmDeleteId(null)}
style={{ className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
background: 'none', border: 'none', fontSize: 12,
color: PURPLE, opacity: 0.6, cursor: 'pointer', fontFamily: 'inherit',
}}
>
No
</button>
</>
) : (
<>
{!u.isAdmin && (
<button
onClick={() => setConfirmAdminId(u.id)}
disabled={busy}
style={{
background: 'none', border: 'none', padding: 0,
fontSize: 12, color: PURPLE, opacity: busy ? 0.4 : 0.65,
cursor: busy ? 'default' : 'pointer', fontFamily: 'inherit',
}}
>
Admin
</button>
)}
<button
onClick={() => setConfirmDeleteId(u.id)}
disabled={busy}
style={{
background: 'none', border: 'none', padding: 0,
fontSize: 12, color: PURPLE, opacity: busy ? 0.4 : 0.65,
cursor: busy ? 'default' : 'pointer', fontFamily: 'inherit',
}}
>
Delete
</button>
</>
)}
</span>
</div>
);
})}
{!loading && filtered.length === 0 && !error && (
<p style={{ textAlign: 'center', color: PURPLE, opacity: 0.4, fontSize: 13, margin: '32px 0' }}>
{search ? 'No users match your search.' : 'No users found.'}
</p>
)}
</div>
</div>
</div>
</div>
{/* Add user modal */}
{showAddModal && (
<div
onClick={() => setShowAddModal(false)}
style={{
position: 'fixed', inset: 0,
background: 'rgba(63,51,168,0.18)', backdropFilter: 'blur(2px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 100,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: PAPER, borderRadius: 20,
border: '1px solid rgba(91,79,199,0.15)',
padding: '32px 32px 28px',
width: 380, boxShadow: '0 8px 40px rgba(63,51,168,0.14)',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
}}
>
<h2 style={{
margin: '0 0 24px',
fontFamily: "'Rubik Mono One', monospace",
fontSize: 22, fontWeight: 400, letterSpacing: '-0.02em', color: PURPLE,
}}>
Add user
</h2>
<form onSubmit={handleAddUser} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{(['name', 'email', 'password'] as const).map((field) => (
<div key={field}>
<label style={{
display: 'block', fontSize: 10, letterSpacing: '0.16em',
textTransform: 'uppercase', color: PURPLE, opacity: 0.7,
fontWeight: 500, marginBottom: 6,
}}>
{field}
</label>
<input
type={field === 'password' ? 'password' : field === 'email' ? 'email' : 'text'}
value={addForm[field]}
onChange={(e) => setAddForm((prev) => ({ ...prev, [field]: e.target.value }))}
required
style={{
width: '100%', boxSizing: 'border-box',
padding: '9px 13px', borderRadius: 10,
border: '1px solid rgba(91,79,199,0.22)',
background: 'rgba(255,255,255,0.7)',
fontSize: 13, color: PURPLE_DEEP, fontFamily: 'inherit',
outline: 'none',
}}
/>
</div>
))}
{addError && (
<p style={{ margin: 0, fontSize: 12, color: '#c0392b' }}>{addError}</p>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 8 }}>
<button
type="button"
onClick={() => setShowAddModal(false)}
style={{
background: 'none', border: 'none', padding: '8px 14px',
fontSize: 13, color: PURPLE, opacity: 0.65,
cursor: 'pointer', fontFamily: 'inherit',
}}
> >
Cancel Cancel
</button> </button>
</>
) : (
<button <button
type="submit" onClick={() => setConfirmDeleteId(user.id)}
disabled={addSubmitting} disabled={busy || confirmAdminId === user.id}
style={{ className="text-sm font-medium px-4 py-1.5 rounded-lg bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors"
padding: '8px 20px', borderRadius: 99,
background: addSubmitting ? SAGE : PURPLE,
border: `1px solid ${addSubmitting ? SAGE_DEEP : PURPLE_DEEP}`,
color: addSubmitting ? PURPLE_DEEP : '#fff',
fontSize: 12, fontWeight: 500,
cursor: addSubmitting ? 'default' : 'pointer',
fontFamily: 'inherit',
}}
> >
{addSubmitting ? '…' : 'Add user'} Delete
</button>
)}
</div>
</li>
);
}
return (
<div className="min-h-screen bg-slate-50">
<header className="bg-white border-b border-slate-200 px-6 py-4 shadow-sm">
<div className="max-w-3xl mx-auto flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-slate-800">Admin Portal</h1>
<p className="text-xs text-slate-400 mt-0.5">Manage user accounts</p>
</div>
<button
onClick={() => navigate('/')}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
>
Back to planner
</button> </button>
</div> </div>
</form> </header>
</div>
</div> <main className="max-w-3xl mx-auto px-6 py-8 flex flex-col gap-8">
{loading && <p className="text-sm text-slate-400 text-center py-12">Loading</p>}
{error && <p className="text-sm text-red-500 text-center py-4">{error}</p>}
{!loading && (
<>
<section>
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-widest mb-3">
Pending approval
</h2>
{pending.length === 0 ? (
<p className="text-sm text-slate-400">No pending accounts.</p>
) : (
<ul className="flex flex-col gap-3">
{pending.map((u) => <UserRow key={u.id} user={u} />)}
</ul>
)} )}
</section>
<section>
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-widest mb-3">
Approved accounts
</h2>
{approved.length === 0 ? (
<p className="text-sm text-slate-400">No approved accounts yet.</p>
) : (
<ul className="flex flex-col gap-3">
{approved.map((u) => <UserRow key={u.id} user={u} />)}
</ul>
)}
</section>
</>
)}
</main>
</div> </div>
); );
} }

View File

@@ -6,34 +6,6 @@ interface AuthPageProps {
onAuth: (auth: AuthResponse) => void; onAuth: (auth: AuthResponse) => void;
} }
const PURPLE = '#5b4fc7';
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
const PAPER = '#f4f3ee';
const inputStyle: React.CSSProperties = {
padding: '12px 14px',
borderRadius: 12,
border: '1px solid rgba(91,79,199,0.18)',
background: PAPER,
color: PURPLE_DEEP,
fontSize: 14,
fontFamily: 'inherit',
outline: 'none',
width: '100%',
boxSizing: 'border-box',
};
const fieldLabelStyle: React.CSSProperties = {
fontSize: 11,
letterSpacing: '0.14em',
textTransform: 'uppercase',
color: PURPLE,
opacity: 0.7,
fontWeight: 500,
};
export function AuthPage({ onAuth }: AuthPageProps) { export function AuthPage({ onAuth }: AuthPageProps) {
const [mode, setMode] = useState<'login' | 'register' | 'pending'>('login'); const [mode, setMode] = useState<'login' | 'register' | 'pending'>('login');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -42,7 +14,7 @@ export function AuthPage({ onAuth }: AuthPageProps) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: { preventDefault: () => void }) {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setLoading(true); setLoading(true);
@@ -62,220 +34,101 @@ export function AuthPage({ onAuth }: AuthPageProps) {
} }
return ( return (
<div style={{ background: 'var(--bg)', minHeight: '100vh', display: 'flex' }}> <div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center px-4">
<div style={{ <div className="w-full max-w-sm">
maxWidth: 1100, <div className="text-center mb-8">
width: '100%', <h1 className="text-2xl font-semibold text-slate-800">Office Planner</h1>
margin: '0 auto', <p className="text-sm text-slate-400 mt-1">Reserve your workspace</p>
height: '100vh',
display: 'flex',
position: 'relative',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
}}>
{/* Wordmark */}
<div style={{
position: 'absolute', top: 22, left: 36,
display: 'flex', alignItems: 'center', gap: 1,
color: PURPLE,
fontFamily: "'Rubik Mono One', monospace",
fontSize: 18, letterSpacing: '-0.02em',
}}>
<span style={{ opacity: 0.6 }}>{'{'}</span>
<span style={{ padding: '0 4px' }}>randall</span>
<span style={{ opacity: 0.6 }}>{'}'}</span>
</div> </div>
{/* Left editorial column */} {mode === 'pending' && (
<div style={{ <div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8 text-center">
flex: 1, <div className="text-3xl mb-4"></div>
display: 'flex', <h2 className="text-base font-semibold text-slate-800 mb-2">Account pending approval</h2>
flexDirection: 'column', <p className="text-sm text-slate-500 mb-6">
justifyContent: 'center',
padding: '0 80px',
}}>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 14, fontWeight: 500,
}}>
The Hague HQ · Sign in
</div>
<h1 style={{
margin: 0,
fontFamily: "'Rubik Mono One', monospace",
fontSize: 54, fontWeight: 400,
letterSpacing: '-0.02em', lineHeight: 0.95,
color: PURPLE, maxWidth: 520,
}}>
Find your <span style={{ color: SAGE_DEEP }}>desk.</span>
</h1>
<p style={{
margin: '14px 0 0', maxWidth: 480,
fontSize: 14, lineHeight: 1.55,
color: PURPLE, opacity: 0.85,
}}>
Reserve a desk for today or up to two weeks ahead. Sign in with your Randall email.
</p>
</div>
{/* Right card column */}
<div style={{
width: 430,
display: 'flex',
alignItems: 'center',
padding: '0 60px 0 0',
}}>
{mode === 'pending' ? (
<div style={{
width: '100%', background: PAPER, borderRadius: 18, padding: 28,
border: '1px solid rgba(91,79,199,0.10)',
}}>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 16, fontWeight: 500,
}}>
Account pending
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 32, color: PURPLE, lineHeight: 0.95,
letterSpacing: '-0.02em', marginBottom: 12,
}}>
Almost there.
</div>
<p style={{ fontSize: 13, color: PURPLE, opacity: 0.75, lineHeight: 1.5, margin: '0 0 22px' }}>
Your account has been created. An administrator will review and approve it shortly. Your account has been created. An administrator will review and approve it shortly.
</p> </p>
<button <button
onClick={() => { setMode('login'); setError(''); }} onClick={() => { setMode('login'); setError(''); }}
style={{ className="text-sm text-emerald-600 hover:text-emerald-700 font-medium"
background: 'none', border: 'none', padding: 0,
fontSize: 13, color: PURPLE, opacity: 0.7, cursor: 'pointer',
fontFamily: 'inherit', textDecoration: 'underline',
}}
> >
Back to sign in Back to sign in
</button> </button>
</div> </div>
) : ( )}
<div style={{
width: '100%', background: PAPER, borderRadius: 18, padding: 28, {mode !== 'pending' && <div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
border: '1px solid rgba(91,79,199,0.10)', <div className="flex rounded-lg bg-slate-100 p-1 mb-6">
}}>
{/* Tab switcher */}
<div style={{
display: 'flex', gap: 6, marginBottom: 22,
background: 'rgba(91,79,199,0.06)', padding: 4, borderRadius: 99,
}}>
{(['login', 'register'] as const).map((m) => (
<button <button
key={m} onClick={() => { setMode('login'); setError(''); }}
onClick={() => { setMode(m); setError(''); }} className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
style={{ mode === 'login' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'
flex: 1, padding: '8px 10px', borderRadius: 99, border: 'none', }`}
background: mode === m ? PAPER : 'transparent',
color: mode === m ? PURPLE_DEEP : PURPLE,
fontWeight: 500, fontSize: 12, cursor: 'pointer', fontFamily: 'inherit',
boxShadow: mode === m ? '0 2px 6px -2px rgba(91,79,199,0.18)' : 'none',
}}
> >
{m === 'login' ? 'Sign in' : 'Register'} Sign in
</button>
<button
onClick={() => { setMode('register'); setError(''); }}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
mode === 'register' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
Create account
</button> </button>
))}
</div> </div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={fieldLabelStyle}>Email</span>
<input
type="email"
required
placeholder="you@randall.local"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={inputStyle}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={fieldLabelStyle}>Password</span>
<input
type="password"
required
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={inputStyle}
/>
</label>
{mode === 'register' && ( {mode === 'register' && (
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div>
<span style={fieldLabelStyle}>Full name</span> <label className="block text-sm font-medium text-slate-700 mb-1">Full name</label>
<input <input
type="text" type="text"
required required
placeholder="Jane Smith"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
style={inputStyle} placeholder="Jane Smith"
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400"
/> />
</label> </div>
)} )}
{error && ( <div>
<div style={{ <label className="block text-sm font-medium text-slate-700 mb-1">Work email</label>
fontSize: 13, color: '#c0392b', <input
background: 'rgba(192,57,43,0.08)', type="email"
borderRadius: 8, padding: '10px 14px', required
}}> value={email}
{error} onChange={(e) => setEmail(e.target.value)}
placeholder="jane@company.com"
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400"
/>
</div> </div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Password</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400"
/>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)} )}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
style={{ className="w-full py-2 text-sm font-medium text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors mt-1"
padding: '13px 16px', borderRadius: 99, marginTop: 6,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 13, fontWeight: 500,
cursor: loading ? 'default' : 'pointer', fontFamily: 'inherit',
opacity: loading ? 0.6 : 1,
}}
> >
{loading ? '…' : mode === 'login' ? 'Sign in' : 'Create account'} {loading ? '…' : mode === 'login' ? 'Sign in' : 'Create account'}
</button> </button>
</form> </form>
</div>}
<div style={{
textAlign: 'center', fontSize: 12, color: PURPLE,
opacity: 0.7, marginTop: 16,
}}>
{mode === 'login' ? (
<>No account?{' '}
<span
onClick={() => { setMode('register'); setError(''); }}
style={{ color: PURPLE, cursor: 'pointer', textDecoration: 'underline' }}
>
Register
</span>
</>
) : (
<>Have one?{' '}
<span
onClick={() => { setMode('login'); setError(''); }}
style={{ color: PURPLE, cursor: 'pointer', textDecoration: 'underline' }}
>
Sign in
</span>
</>
)}
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -7,12 +7,6 @@ import { ReservationModal } from '../components/ReservationModal';
import { CancelModal } from '../components/CancelModal'; import { CancelModal } from '../components/CancelModal';
import { MyReservations } from '../components/MyReservations'; import { MyReservations } from '../components/MyReservations';
const PURPLE = '#5b4fc7';
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
const PAPER = '#f4f3ee';
function toIsoDate(date: Date): string { function toIsoDate(date: Date): string {
const m = String(date.getMonth() + 1).padStart(2, '0'); const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0');
@@ -31,49 +25,6 @@ function formatDisplayDate(isoDate: string): string {
}); });
} }
function formatKickerDate(isoDate: string): string {
const [y, m, d] = isoDate.split('-').map(Number);
const date = new Date(y, m - 1, d);
const weekday = date.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase();
const month = date.toLocaleDateString('en-GB', { month: 'short' }).toUpperCase();
return `${weekday}, ${d} ${month} · THE HAGUE HQ`;
}
function formatRelativeTime(isoString: string): string {
const created = new Date(isoString);
const now = new Date();
const mins = Math.floor((now.getTime() - created.getTime()) / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins} min ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return 'Earlier today';
}
function getDayOffset(isoDate: string, isoToday: string): number {
const [y1, m1, d1] = isoDate.split('-').map(Number);
const [y2, m2, d2] = isoToday.split('-').map(Number);
const a = new Date(y1, m1 - 1, d1).getTime();
const b = new Date(y2, m2 - 1, d2).getTime();
return Math.round((a - b) / (1000 * 60 * 60 * 24));
}
function getTrailingWord(dayOffset: number): string {
if (dayOffset === 0) return 'today?';
if (dayOffset === 1) return 'tomorrow?';
return 'then?';
}
function formatDayCell(isoDate: string): { weekday: string; day: number; monthTag: string | null } {
const [y, m, d] = isoDate.split('-').map(Number);
const date = new Date(y, m - 1, d);
const weekday = date.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase().slice(0, 3);
const monthTag = d === 1
? date.toLocaleDateString('en-GB', { month: 'short' }).toUpperCase()
: null;
return { weekday, day: d, monthTag };
}
interface PlannerPageProps { interface PlannerPageProps {
auth: AuthResponse; auth: AuthResponse;
onLogout: () => void; onLogout: () => void;
@@ -83,7 +34,7 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const today = toIsoDate(new Date()); const today = toIsoDate(new Date());
const MAX_OFFSET = 13; const maxDate = toIsoDate(new Date(Date.now() + 14 * 24 * 60 * 60 * 1000));
const [selectedDate, setSelectedDate] = useState(today); const [selectedDate, setSelectedDate] = useState(today);
const [schedule, setSchedule] = useState<WorkplaceScheduleItem[]>([]); const [schedule, setSchedule] = useState<WorkplaceScheduleItem[]>([]);
@@ -114,30 +65,6 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
.map((r) => r.workplaceId), .map((r) => r.workplaceId),
); );
const myDeskOnDate = myReservations.find(
(r) => r.status === 'Active' && r.date === selectedDate,
);
const myDeskSchedule = myDeskOnDate
? schedule.find((w) => w.id === myDeskOnDate.workplaceId)
: null;
const neighbours = myDeskSchedule
? schedule
.filter(
(w) =>
w.location === myDeskSchedule.location &&
!w.isAvailable &&
w.id !== myDeskSchedule.id &&
w.reservedBy !== null,
)
.map((w) => w.reservedBy!.split(' ')[0])
.join(', ')
: '';
const freeCount = schedule.filter((w) => w.isAvailable).length;
const dayOffset = getDayOffset(selectedDate, today);
const trailingWord = getTrailingWord(dayOffset);
const dayStrip = Array.from({ length: 14 }, (_, i) => offsetDate(today, i));
function handleDeskClick(desk: WorkplaceScheduleItem) { function handleDeskClick(desk: WorkplaceScheduleItem) {
if (myReservedIdsOnDate.has(desk.id)) { if (myReservedIdsOnDate.has(desk.id)) {
const res = myReservations.find( const res = myReservations.find(
@@ -166,380 +93,93 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
const podA = schedule.filter((w) => w.location === 'Pod A'); const podA = schedule.filter((w) => w.location === 'Pod A');
const podB = schedule.filter((w) => w.location === 'Pod B'); const podB = schedule.filter((w) => w.location === 'Pod B');
const arrowBtnStyle = (disabled: boolean): React.CSSProperties => ({
background: 'none',
border: 'none',
cursor: disabled ? 'default' : 'pointer',
color: PURPLE,
opacity: disabled ? 0.35 : 0.65,
fontSize: 16,
padding: '0 4px',
fontFamily: 'inherit',
flexShrink: 0,
lineHeight: 1,
});
return ( return (
/* Outer bg fills the full viewport */ <div className="min-h-screen bg-slate-50">
<div style={{ background: 'var(--bg)', minHeight: '100vh' }}> <header className="bg-white border-b border-slate-200 px-6 py-4 shadow-sm">
{/* Centred 1100 px frame */} <div className="max-w-5xl mx-auto flex items-center justify-between">
<div style={{ <div>
maxWidth: 1100, <h1 className="text-xl font-semibold text-slate-800">Office Planner</h1>
margin: '0 auto', <p className="text-xs text-slate-400 mt-0.5">Reserve your workspace up to 2 weeks ahead</p>
height: '100vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Header */}
<header style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '22px 36px',
flexShrink: 0,
}}>
{/* Wordmark */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 1,
color: PURPLE,
fontFamily: "'Rubik Mono One', 'Major Mono Display', monospace",
fontSize: 18,
letterSpacing: '-0.02em',
}}>
<span style={{ fontWeight: 400, opacity: 0.6 }}>{'{'}</span>
<span style={{ padding: '0 4px' }}>randall</span>
<span style={{ fontWeight: 400, opacity: 0.6 }}>{'}'}</span>
</div> </div>
<div className="flex items-center gap-4">
{/* Nav */} <span className="text-sm text-slate-600">{auth.name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 28, fontSize: 13, color: PURPLE }}>
<span style={{ fontWeight: 500 }}>Today</span>
{auth.isAdmin && ( {auth.isAdmin && (
<button <button
onClick={() => navigate('/admin')} onClick={() => navigate('/admin')}
style={{ className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
background: 'none', border: 'none', fontFamily: 'inherit',
fontSize: 13, color: PURPLE, opacity: 0.6, cursor: 'pointer', padding: 0,
}}
> >
Admin Admin portal
</button> </button>
)} )}
<button <button
onClick={onLogout} onClick={onLogout}
style={{ className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
padding: '7px 18px',
borderRadius: 99,
background: SAGE,
border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP,
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
fontFamily: 'inherit',
}}
> >
Sign out Sign out
</button> </button>
<span style={{ fontSize: 12, color: PURPLE, opacity: 0.6, cursor: 'default' }}>NL </span> </div>
</div> </div>
</header> </header>
{/* Body */} <main className="max-w-5xl mx-auto px-6 py-8 flex flex-col gap-8">
<div style={{ <section className="flex items-center gap-3 flex-wrap">
display: 'flex', <label className="text-sm font-medium text-slate-600 whitespace-nowrap">Date</label>
flex: 1,
gap: 32,
padding: '8px 36px 32px',
minHeight: 0,
overflow: 'hidden',
}}>
{/* Left column */}
<div style={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: 22,
overflow: 'auto',
}}>
{/* Editorial hero */}
<div>
{/* Kicker */}
<div style={{
fontSize: 11,
letterSpacing: '0.18em',
textTransform: 'uppercase',
color: PURPLE,
opacity: 0.7,
fontWeight: 500,
marginBottom: 14,
}}>
{formatKickerDate(selectedDate)}
</div>
{/* Headline */}
<h1 style={{
margin: 0,
fontFamily: "'Rubik Mono One', 'Major Mono Display', monospace",
fontSize: 46,
fontWeight: 400,
letterSpacing: '-0.02em',
lineHeight: 0.95,
color: PURPLE,
}}>
Where to sit{' '}
<span style={{ color: SAGE_DEEP }}>{trailingWord}</span>
</h1>
{/* Body copy */}
<p style={{
margin: '10px 0 0',
maxWidth: 480,
fontSize: 13,
lineHeight: 1.5,
color: PURPLE,
opacity: 0.85,
}}>
{loadingFloor
? 'Loading floor plan…'
: floorError
? floorError
: myDeskOnDate
? `${freeCount} ${freeCount === 1 ? 'desk is' : 'desks are'} free, you're holding ${myDeskOnDate.workplaceName}. Pick another or swap with a teammate.`
: `${freeCount} ${freeCount === 1 ? 'desk is' : 'desks are'} free. Pick one to hold your spot.`}
</p>
{/* 14-day date strip */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 18 }}>
<button <button
onClick={() => setSelectedDate(offsetDate(selectedDate, -1))} onClick={() => setSelectedDate(offsetDate(selectedDate, -1))}
disabled={dayOffset === 0} disabled={selectedDate <= today}
style={arrowBtnStyle(dayOffset === 0)} className="px-2.5 py-1.5 text-sm rounded-lg border border-slate-300 bg-white hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
> >
</button> </button>
<div style={{ display: 'flex', gap: 5, flex: 1 }}> <input
{dayStrip.map((dateIso) => { type="date"
const isActive = dateIso === selectedDate; value={selectedDate}
const { weekday, day, monthTag } = formatDayCell(dateIso); min={today}
return ( max={maxDate}
<button onChange={(e) => setSelectedDate(e.target.value)}
key={dateIso} className="border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400 bg-white"
data-date={dateIso} />
onClick={() => setSelectedDate(dateIso)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: '1 0 0',
padding: '6px 2px',
borderRadius: 10,
background: isActive ? SAGE : 'transparent',
border: isActive
? `1.5px solid ${SAGE_DEEP}`
: '1px solid rgba(91,79,199,0.18)',
cursor: 'pointer',
fontFamily: 'inherit',
gap: 2,
transition: 'background 150ms, border-color 150ms',
}}
>
<span style={{
fontSize: 10,
fontWeight: 500,
letterSpacing: '0.16em',
textTransform: 'uppercase',
color: PURPLE,
opacity: isActive ? 0.85 : 0.55,
lineHeight: 1,
}}>
{weekday}
</span>
<span style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 18,
color: isActive ? PURPLE_DEEP : PURPLE,
lineHeight: 1,
}}>
{day}
</span>
{monthTag && (
<span style={{
fontSize: 8,
fontWeight: 500,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: PURPLE,
opacity: 0.5,
lineHeight: 1,
}}>
{monthTag}
</span>
)}
</button>
);
})}
</div>
<button <button
onClick={() => setSelectedDate(offsetDate(selectedDate, 1))} onClick={() => setSelectedDate(offsetDate(selectedDate, 1))}
disabled={dayOffset >= MAX_OFFSET} disabled={selectedDate >= maxDate}
style={arrowBtnStyle(dayOffset >= MAX_OFFSET)} className="px-2.5 py-1.5 text-sm rounded-lg border border-slate-300 bg-white hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
> >
</button> </button>
</div> <span className="text-sm text-slate-500 font-medium">{formatDisplayDate(selectedDate)}</span>
</div> </section>
{/* Floor card */} <div className="flex gap-6 text-xs text-slate-500">
<div style={{ <span className="flex items-center gap-1.5">
background: PAPER, <span className="w-3 h-3 rounded bg-emerald-400 inline-block" />
borderRadius: 18, Available click to reserve
padding: 22,
border: '1px solid rgba(91,79,199,0.10)',
display: 'flex',
flexDirection: 'column',
}}>
{/* Legend */}
<div style={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
marginBottom: 16,
gap: 14,
fontSize: 11,
color: PURPLE,
opacity: 0.75,
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{
width: 8, height: 8, borderRadius: 2,
background: PAPER, border: '1px solid rgba(91,79,199,0.4)',
display: 'inline-block', flexShrink: 0,
}} />
free
</span> </span>
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}> <span className="flex items-center gap-1.5">
<span style={{ <span className="w-3 h-3 rounded bg-blue-400 inline-block" />
width: 8, height: 8, borderRadius: 2, Your reservation click to cancel
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
display: 'inline-block', flexShrink: 0,
}} />
yours
</span> </span>
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}> <span className="flex items-center gap-1.5">
<span style={{ <span className="w-3 h-3 rounded bg-slate-300 inline-block" />
width: 8, height: 8, borderRadius: 2, Taken hover to see who
border: '1px dashed rgba(91,79,199,0.4)',
display: 'inline-block', flexShrink: 0,
}} />
taken
</span> </span>
</div> </div>
{/* Pods */} <section>
{loadingFloor && <p className="text-sm text-slate-400 text-center py-12">Loading floor plan</p>}
{floorError && <p className="text-sm text-red-500 text-center py-12">{floorError}</p>}
{!loadingFloor && !floorError && ( {!loadingFloor && !floorError && (
<div style={{ <div className="flex flex-wrap gap-12 justify-center">
display: 'flex', <DeskPod desks={podA} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
gap: 36, <DeskPod desks={podB} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
justifyContent: 'center',
alignItems: 'flex-start',
}}>
<DeskPod label="Pod A" desks={podA} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
<DeskPod label="Pod B" desks={podB} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
</div> </div>
)} )}
{loadingFloor && ( </section>
<p style={{ textAlign: 'center', color: PURPLE, opacity: 0.5, fontSize: 13, margin: '32px 0' }}>
Loading
</p>
)}
{floorError && (
<p style={{ textAlign: 'center', color: '#c0392b', fontSize: 13, margin: '32px 0' }}>
{floorError}
</p>
)}
</div>
</div>
{/* Right rail */} <section className="bg-white border border-slate-200 rounded-2xl p-6 shadow-sm">
<div style={{ width: 300, display: 'flex', flexDirection: 'column', gap: 14, overflow: 'auto', flexShrink: 0 }}> <h2 className="text-base font-semibold text-slate-700 mb-4">My reservations</h2>
{/* Your desk card */}
<div style={{
background: PAPER,
borderRadius: 18,
padding: 22,
border: '1px solid rgba(91,79,199,0.10)',
flexShrink: 0,
}}>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500,
}}>
Your desk
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 46, letterSpacing: '-0.03em', color: PURPLE,
lineHeight: 0.95, marginBottom: 6,
}}>
{myDeskOnDate?.workplaceName ?? '—'}
</div>
<div style={{ fontSize: 13, color: PURPLE, opacity: 0.75, marginBottom: 18 }}>
{myDeskOnDate
? `${myDeskOnDate.workplaceLocation} · Held ${formatRelativeTime(myDeskOnDate.createdAt)}`
: 'No desk held for this day'}
</div>
<div style={{
display: 'flex', flexDirection: 'column', gap: 7,
fontSize: 13, color: PURPLE, marginBottom: 18,
borderTop: '1px solid rgba(91,79,199,0.10)', paddingTop: 14,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ opacity: 0.65 }}>Date</span>
<span>{formatDisplayDate(selectedDate)}</span>
</div>
{myDeskOnDate && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ opacity: 0.65 }}>Neighbours</span>
<span style={{ textAlign: 'right', maxWidth: 140 }}>
{neighbours || '—'}
</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ opacity: 0.65 }}>Streak</span>
<span></span>
</div>
</div>
</div>
{/* My reservations card */}
<div style={{
background: PAPER,
borderRadius: 18,
padding: 22,
border: '1px solid rgba(91,79,199,0.10)',
}}>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 14, fontWeight: 500,
}}>
My reservations
</div>
<MyReservations reservations={myReservations} onCancel={setCancelTarget} /> <MyReservations reservations={myReservations} onCancel={setCancelTarget} />
</div> </section>
</div> </main>
</div>
</div>
{reserveTarget && ( {reserveTarget && (
<ReservationModal <ReservationModal

View File

@@ -7,7 +7,7 @@ export default defineConfig({
retries: 0, retries: 0,
reporter: [['list'], ['html', { open: 'never' }]], reporter: [['list'], ['html', { open: 'never' }]],
use: { use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:5173', baseURL: process.env.BASE_URL ?? 'http://localhost',
trace: 'on-first-retry', trace: 'on-first-retry',
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
headless: !!process.env.CI, headless: !!process.env.CI,

View File

@@ -6,96 +6,41 @@ test.describe('Admin portal', () => {
await loginAsAdmin(page); await loginAsAdmin(page);
}); });
test('admin user sees the Admin button in the header', async ({ page }) => { test('admin user sees the Admin portal button in the header', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Admin' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Admin portal' })).toBeVisible();
}); });
test('navigates to the admin portal', async ({ page }) => { test('navigates to the admin portal', async ({ page }) => {
await page.getByRole('button', { name: 'Admin' }).click(); await page.getByRole('button', { name: 'Admin portal' }).click();
await page.waitForURL('/admin'); await page.waitForURL('/admin');
await expect(page.getByRole('heading', { name: /Who can book/i })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Admin Portal' })).toBeVisible();
await expect(page.getByText(/Users.*The Hague HQ/i)).toBeVisible(); await expect(page.getByText('Manage user accounts')).toBeVisible();
}); });
test('admin portal lists the admin account', async ({ page }) => { test('admin portal lists the admin account under Approved accounts', async ({ page }) => {
await page.getByRole('button', { name: 'Admin' }).click(); await page.goto('/admin');
await page.waitForURL('/admin');
await expect(page.getByText('admin@randall.local')).toBeVisible(); await expect(page.getByText('Approved accounts')).toBeVisible();
const adminRow = page.locator('[data-testid="user-row"]').filter({ hasText: 'admin@randall.local' }); // Target the name paragraph inside the admin's list item
await expect(adminRow.locator('[data-testid="role-badge"]').filter({ hasText: /^admin$/i })).toBeVisible(); const adminRow = page.locator('li').filter({ hasText: 'admin@randall.local' });
await expect(adminRow.getByRole('paragraph').filter({ hasText: /^Admin$/ })).toBeVisible();
}); });
test('admin account has the admin role badge and no make-admin button', async ({ page }) => { test('admin account has the Admin badge and no Make admin button', async ({ page }) => {
await page.getByRole('button', { name: 'Admin' }).click(); await page.goto('/admin');
await page.waitForURL('/admin');
const adminRow = page.locator('[data-testid="user-row"]').filter({ hasText: 'admin@randall.local' }); const adminRow = page.locator('li').filter({ hasText: 'admin@randall.local' });
await expect(adminRow.locator('[data-testid="role-badge"]').filter({ hasText: /^admin$/i })).toBeVisible(); // The badge is a <span> with class bg-amber-100
// The make-admin action button is not rendered for users who are already admin await expect(adminRow.locator('span').filter({ hasText: /^Admin$/ })).toBeVisible();
await expect(adminRow.getByRole('button', { name: 'Admin' })).not.toBeVisible(); await expect(adminRow.getByRole('button', { name: 'Make admin' })).not.toBeVisible();
}); });
test('wordmark navigates back to the planner', async ({ page }) => { test('Back to planner link returns to the planner', async ({ page }) => {
await page.getByRole('button', { name: 'Admin' }).click(); await page.goto('/admin');
await page.waitForURL('/admin'); await page.getByRole('button', { name: '← Back to planner' }).click();
await page.locator('span', { hasText: 'randall' }).first().click();
await page.waitForURL('/'); await page.waitForURL('/');
await expect(page.getByRole('heading', { name: /Where to sit/i })).toBeVisible(); await expect(page.getByText('Reserve your workspace up to 2 weeks ahead')).toBeVisible();
});
test('+ Add user button opens the add-user modal', async ({ page }) => {
await page.getByRole('button', { name: 'Admin' }).click();
await page.waitForURL('/admin');
await page.getByRole('button', { name: '+ Add user' }).click();
await expect(page.getByRole('heading', { name: 'Add user' })).toBeVisible();
await expect(page.locator('form input[type="text"]')).toBeVisible();
await expect(page.locator('form input[type="email"]')).toBeVisible();
await expect(page.locator('form input[type="password"]')).toBeVisible();
});
test('add-user modal closes when Cancel is clicked', async ({ page }) => {
await page.getByRole('button', { name: 'Admin' }).click();
await page.waitForURL('/admin');
await page.getByRole('button', { name: '+ Add user' }).click();
await expect(page.getByRole('heading', { name: 'Add user' })).toBeVisible();
await page.locator('form').getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('heading', { name: 'Add user' })).not.toBeVisible();
});
test('can add a new user via the modal and see them in the table', async ({ page }) => {
await page.getByRole('button', { name: 'Admin' }).click();
await page.waitForURL('/admin');
const email = `adduser+${Date.now()}@test.com`;
await page.getByRole('button', { name: '+ Add user' }).click();
await page.locator('form input[type="text"]').fill('New Test User');
await page.locator('form input[type="email"]').fill(email);
await page.locator('form input[type="password"]').fill('Test@1234');
await page.locator('form button[type="submit"]').click();
await expect(page.getByText(email)).toBeVisible();
});
test('add-user modal shows an error for a duplicate email', async ({ page }) => {
await page.getByRole('button', { name: 'Admin' }).click();
await page.waitForURL('/admin');
await page.getByRole('button', { name: '+ Add user' }).click();
await page.locator('form input[type="text"]').fill('Duplicate');
await page.locator('form input[type="email"]').fill('admin@randall.local');
await page.locator('form input[type="password"]').fill('Test@1234');
await page.locator('form button[type="submit"]').click();
await expect(page.getByText(/already exists/i)).toBeVisible();
await expect(page.getByRole('heading', { name: 'Add user' })).toBeVisible();
}); });
}); });

View File

@@ -7,22 +7,22 @@ test.describe('Authentication', () => {
}); });
test('shows the sign-in form on initial load', async ({ page }) => { test('shows the sign-in form on initial load', async ({ page }) => {
await expect(page.getByRole('heading', { name: /Find your desk/i })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Office Planner' })).toBeVisible();
await expect(page.getByPlaceholder('you@randall.local')).toBeVisible(); await expect(page.getByPlaceholder('jane@company.com')).toBeVisible();
await expect(page.getByPlaceholder('••••••••')).toBeVisible(); await expect(page.getByPlaceholder('••••••••')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toHaveText('Sign in'); await expect(page.locator('button[type="submit"]')).toHaveText('Sign in');
}); });
test('signs in with valid credentials and reaches the planner', async ({ page }) => { test('signs in with valid credentials and reaches the planner', async ({ page }) => {
await page.getByPlaceholder('you@randall.local').fill(ADMIN_EMAIL); await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD); await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
await page.locator('button[type="submit"]').click(); await page.locator('button[type="submit"]').click();
await expect(page.getByRole('heading', { name: /Where to sit/i })).toBeVisible(); await expect(page.getByText('Reserve your workspace up to 2 weeks ahead')).toBeVisible();
}); });
test('shows an error for an unknown email', async ({ page }) => { test('shows an error for an unknown email', async ({ page }) => {
await page.getByPlaceholder('you@randall.local').fill('nobody@example.com'); await page.getByPlaceholder('jane@company.com').fill('nobody@example.com');
await page.getByPlaceholder('••••••••').fill('anything'); await page.getByPlaceholder('••••••••').fill('anything');
await page.locator('button[type="submit"]').click(); await page.locator('button[type="submit"]').click();
@@ -30,7 +30,7 @@ test.describe('Authentication', () => {
}); });
test('shows an error for a wrong password', async ({ page }) => { test('shows an error for a wrong password', async ({ page }) => {
await page.getByPlaceholder('you@randall.local').fill(ADMIN_EMAIL); await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
await page.getByPlaceholder('••••••••').fill('wrongpassword'); await page.getByPlaceholder('••••••••').fill('wrongpassword');
await page.locator('button[type="submit"]').click(); await page.locator('button[type="submit"]').click();
@@ -39,25 +39,25 @@ test.describe('Authentication', () => {
test('registers a new account and shows pending approval message', async ({ page }) => { test('registers a new account and shows pending approval message', async ({ page }) => {
// Switch to register mode // Switch to register mode
await page.locator('button').filter({ hasText: 'Register' }).first().click(); await page.locator('button').filter({ hasText: 'Create account' }).first().click();
await page.getByPlaceholder('you@randall.local').fill(`testuser+${Date.now()}@example.com`);
await page.getByPlaceholder('••••••••').fill('Test@1234');
await page.getByPlaceholder('Jane Smith').fill('Test User'); await page.getByPlaceholder('Jane Smith').fill('Test User');
await page.getByPlaceholder('jane@company.com').fill(`testuser+${Date.now()}@example.com`);
await page.getByPlaceholder('••••••••').fill('Test@1234');
await page.locator('button[type="submit"]').click(); await page.locator('button[type="submit"]').click();
await expect(page.getByText('Almost there.')).toBeVisible(); await expect(page.getByText('Account pending approval')).toBeVisible();
await expect(page.getByText(/administrator will review/i)).toBeVisible(); await expect(page.getByText(/administrator will review/i)).toBeVisible();
}); });
test('signs out and returns to the sign-in form', async ({ page }) => { test('signs out and returns to the sign-in form', async ({ page }) => {
await page.getByPlaceholder('you@randall.local').fill(ADMIN_EMAIL); await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD); await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
await page.locator('button[type="submit"]').click(); await page.locator('button[type="submit"]').click();
await page.waitForURL('/'); await page.waitForURL('/');
await page.getByRole('button', { name: 'Sign out' }).click(); await page.getByRole('button', { name: 'Sign out' }).click();
await expect(page.locator('button[type="submit"]')).toHaveText('Sign in'); await expect(page.locator('button[type="submit"]')).toHaveText('Sign in');
}); });
}); });

View File

@@ -6,25 +6,20 @@ export const ADMIN_NAME = 'Admin';
export async function loginAs(page: Page, email: string, password: string) { export async function loginAs(page: Page, email: string, password: string) {
await page.goto('/'); await page.goto('/');
await page.getByPlaceholder('you@randall.local').fill(email); await page.getByPlaceholder('jane@company.com').fill(email);
await page.getByPlaceholder('••••••••').fill(password); await page.getByPlaceholder('••••••••').fill(password);
await page.locator('button[type="submit"]').click(); await page.locator('button[type="submit"]').click();
await page.waitForURL('/'); await page.waitForURL('/');
await page.locator('h1').waitFor(); await page.getByText('Reserve your workspace up to 2 weeks ahead').waitFor();
} }
export async function loginAsAdmin(page: Page) { export async function loginAsAdmin(page: Page) {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD); await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
} }
/** Returns today's date offset by `days` in yyyy-MM-dd format. */ /** Returns today's date offset by `days` in yyyy-MM-dd format */
export function offsetDate(days: number): string { export function offsetDate(days: number): string {
const d = new Date(); const d = new Date();
d.setDate(d.getDate() + days); d.setDate(d.getDate() + days);
return d.toISOString().split('T')[0]; return d.toISOString().split('T')[0];
} }
/** Clicks the day-strip cell for today + `days` offset. */
export async function selectDate(page: Page, days: number) {
await page.locator(`[data-date="${offsetDate(days)}"]`).click();
}

View File

@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { loginAsAdmin, offsetDate, selectDate } from './helpers'; import { loginAsAdmin, offsetDate } from './helpers';
test.describe('Planner', () => { test.describe('Planner', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@@ -8,24 +8,20 @@ test.describe('Planner', () => {
test('displays both pods with 8 desks each', async ({ page }) => { test('displays both pods with 8 desks each', async ({ page }) => {
// Wait for floor plan to load — at least one free desk must be visible // Wait for floor plan to load — at least one free desk must be visible
await expect(page.getByRole('button', { name: /^D1 free$/i })).toBeVisible(); await expect(page.getByRole('button', { name: 'D1 Free' })).toBeVisible();
// All 16 desk buttons contain a desk label (D1D16) in their text content // All 16 desk buttons contain a desk label (D1D16) in their text content
const allDesks = page.getByRole('button').filter({ hasText: /^D\d+/ }); const allDesks = page.getByRole('button').filter({ hasText: /D\d+/ });
await expect(allDesks).toHaveCount(16); await expect(allDesks).toHaveCount(16);
}); });
test('date strip shows 14 days and respects boundaries', async ({ page }) => { test('date picker is constrained to today and 14 days ahead', async ({ page }) => {
// 14 day-strip cells are rendered (one per bookable day) const input = page.locator('input[type="date"]');
await expect(page.locator('[data-date]')).toHaveCount(14); const today = offsetDate(0);
const max = offsetDate(14);
// Today and the last bookable day are both present await expect(input).toHaveAttribute('min', today);
await expect(page.locator(`[data-date="${offsetDate(0)}"]`)).toBeVisible(); await expect(input).toHaveAttribute('max', max);
await expect(page.locator(`[data-date="${offsetDate(13)}"]`)).toBeVisible();
// Clicking the last day disables the forward arrow
await page.locator(`[data-date="${offsetDate(13)}"]`).click();
await expect(page.getByRole('button', { name: '→' })).toBeDisabled();
}); });
test('previous-day button is disabled when on today', async ({ page }) => { test('previous-day button is disabled when on today', async ({ page }) => {
@@ -33,60 +29,72 @@ test.describe('Planner', () => {
}); });
test('next-day button is disabled when on the maximum date', async ({ page }) => { test('next-day button is disabled when on the maximum date', async ({ page }) => {
await page.locator(`[data-date="${offsetDate(13)}"]`).click(); const input = page.locator('input[type="date"]');
await input.fill(offsetDate(14));
await input.dispatchEvent('change');
await expect(page.getByRole('button', { name: '→' })).toBeDisabled(); await expect(page.getByRole('button', { name: '→' })).toBeDisabled();
}); });
test('reserves a desk and shows it as yours', async ({ page }) => { test('reserves a desk and shows it as Mine', async ({ page }) => {
await selectDate(page, 7); const targetDate = offsetDate(7);
const input = page.locator('input[type="date"]');
await input.fill(targetDate);
await input.dispatchEvent('change');
await page.getByRole('button', { name: /^D1 free$/i }).click(); await page.getByRole('button', { name: 'D1 Free' }).click();
await expect(page.getByRole('dialog').getByText('Reserve desk')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Reserve desk' })).toBeVisible();
await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('button', { name: /^D1 yours$/i })).toBeVisible(); await expect(page.getByRole('button', { name: /^D1\s+Mine/ })).toBeVisible();
// Clean up // Clean up
await page.getByRole('button', { name: /^D1 yours$/i }).click(); await page.getByRole('button', { name: /^D1\s+Mine/ }).click();
await page.getByRole('button', { name: 'Cancel reservation' }).click(); await page.getByRole('button', { name: 'Cancel reservation' }).click();
}); });
test('reserved desk appears in My reservations', async ({ page }) => { test('reserved desk appears in My reservations', async ({ page }) => {
await selectDate(page, 8); const targetDate = offsetDate(8);
const input = page.locator('input[type="date"]');
await input.fill(targetDate);
await input.dispatchEvent('change');
await page.getByRole('button', { name: /D2.*free/i }).click(); await page.getByRole('button', { name: 'D2 Free' }).click();
await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByText('My reservations')).toBeVisible(); await expect(page.getByRole('heading', { name: 'My reservations' })).toBeVisible();
await expect(page.locator('li').filter({ hasText: 'D2' })).toBeVisible(); await expect(page.locator('section').filter({ hasText: 'My reservations' }).getByText('D2')).toBeVisible();
// Clean up // Clean up
await page.getByRole('button', { name: /D2.*yours/i }).click(); await page.getByRole('button', { name: /^D2\s+Mine/ }).click();
await page.getByRole('button', { name: 'Cancel reservation' }).click(); await page.getByRole('button', { name: 'Cancel reservation' }).click();
}); });
test('cancels a reservation and desk returns to free', async ({ page }) => { test('cancels a reservation and desk returns to Free', async ({ page }) => {
await selectDate(page, 9); const targetDate = offsetDate(9);
const input = page.locator('input[type="date"]');
await input.fill(targetDate);
await input.dispatchEvent('change');
// Reserve // Reserve
await page.getByRole('button', { name: /D3.*free/i }).click(); await page.getByRole('button', { name: 'D3 Free' }).click();
await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('button', { name: /D3.*yours/i })).toBeVisible(); await expect(page.getByRole('button', { name: /^D3\s+Mine/ })).toBeVisible();
// Cancel // Cancel
await page.getByRole('button', { name: /D3.*yours/i }).click(); await page.getByRole('button', { name: /^D3\s+Mine/ }).click();
await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Cancel reservation' })).toBeVisible();
await page.getByRole('button', { name: 'Cancel reservation' }).click(); await page.getByRole('button', { name: 'Cancel reservation' }).click();
await expect(page.getByRole('button', { name: /D3.*free/i })).toBeVisible(); await expect(page.getByRole('button', { name: 'D3 Free' })).toBeVisible();
}); });
test('dismisses the reservation modal when clicking Cancel', async ({ page }) => { test('dismisses the reservation modal when clicking Cancel', async ({ page }) => {
await page.getByRole('button', { name: /D4.*free/i }).click(); await page.getByRole('button', { name: 'D4 Free' }).click();
await expect(page.getByRole('dialog').getByText('Reserve desk')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Reserve desk' })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click(); // Scope to the modal overlay to avoid matching Cancel buttons in My reservations
await expect(page.getByRole('dialog')).not.toBeVisible(); await page.locator('.fixed.inset-0').getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('heading', { name: 'Reserve desk' })).not.toBeVisible();
}); });
}); });