Compare commits
16 Commits
claude/elo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d41559f013 | |||
| 161a5aa85e | |||
| 3a63ec8dcc | |||
| 74a05253e7 | |||
|
|
4468e7b891 | ||
|
|
da698224a7 | ||
|
|
72751f6491 | ||
|
|
a7ad3d6ebf | ||
|
|
b09b40b888 | ||
|
|
bc64d2ad5c | ||
|
|
106acedce8 | ||
|
|
649c11b21a | ||
|
|
4c71a1b4c8 | ||
|
|
cba080acfe | ||
|
|
d28daa8361 | ||
|
|
5677e4626f |
31
.claude/commands/commit.md
Normal file
31
.claude/commands/commit.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 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
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -53,6 +53,8 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker
|
||||
|
||||
- name: Build images
|
||||
run: docker compose -f cicd/docker/docker-compose.yml build
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -52,3 +52,10 @@ tests/e2e/.cache/
|
||||
# -------------------------
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# -------------------------
|
||||
# Claude
|
||||
# -------------------------
|
||||
.claude/settings.local.json
|
||||
.claude/cache/
|
||||
.claude/sessions/
|
||||
@@ -8,12 +8,14 @@ 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.Infrastructure/Randall.Infrastructure.csproj src/backend/src/Randall.Infrastructure/
|
||||
COPY src/backend/src/Randall.Api/Randall.Api.csproj src/backend/src/Randall.Api/
|
||||
RUN dotnet restore "src/backend/src/Randall.Api/Randall.Api.csproj"
|
||||
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
||||
dotnet restore "src/backend/src/Randall.Api/Randall.Api.csproj"
|
||||
|
||||
# Build — copy source only (no bin/obj/db files)
|
||||
COPY src/backend/src/ src/backend/src/
|
||||
RUN dotnet publish "src/backend/src/Randall.Api/Randall.Api.csproj" \
|
||||
-c Release -o /app/publish
|
||||
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
||||
dotnet publish "src/backend/src/Randall.Api/Randall.Api.csproj" \
|
||||
-c Release --no-restore -o /app/publish
|
||||
|
||||
# Runtime stage
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# Build stage
|
||||
FROM node:22-alpine AS build
|
||||
FROM public.ecr.aws/docker/library/node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies — copy lockfiles first to cache the npm layer
|
||||
COPY src/frontend/package.json src/frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
RUN --mount=type=cache,id=npm,target=/root/.npm \
|
||||
npm ci --prefer-offline
|
||||
|
||||
# Copy source
|
||||
COPY src/frontend/index.html ./
|
||||
@@ -17,7 +18,7 @@ COPY src/frontend/public/ ./public/
|
||||
RUN npm run build
|
||||
|
||||
# Serve stage
|
||||
FROM nginx:alpine AS runtime
|
||||
FROM public.ecr.aws/docker/library/nginx:alpine AS runtime
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY cicd/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
services:
|
||||
backend:
|
||||
image: localhost/randall/backend:latest
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: cicd/docker/Dockerfile.backend
|
||||
@@ -20,6 +21,7 @@ services:
|
||||
start_period: 10s
|
||||
|
||||
frontend:
|
||||
image: localhost/randall/frontend:latest
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: cicd/docker/Dockerfile.frontend
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Randall.Application.Admin.AddUser;
|
||||
using Randall.Application.Admin.ApproveUser;
|
||||
using Randall.Application.Admin.DeleteUser;
|
||||
using Randall.Application.Admin.GetAllUsers;
|
||||
@@ -9,10 +10,13 @@ using Randall.Application.Admin.MakeAdmin;
|
||||
|
||||
namespace Randall.Api.Admin;
|
||||
|
||||
public record AddUserRequest(string Email, string Name, string Password);
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
[Authorize]
|
||||
public class AdminController(
|
||||
AddUserHandler addUserHandler,
|
||||
GetAllUsersHandler getAllUsersHandler,
|
||||
GetPendingUsersHandler getPendingUsersHandler,
|
||||
ApproveUserHandler approveUserHandler,
|
||||
@@ -23,6 +27,16 @@ public class AdminController(
|
||||
private Guid RequesterId => Guid.Parse(User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier)
|
||||
?? 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")]
|
||||
public async Task<IActionResult> GetAllUsers(CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Admin.AddUser;
|
||||
|
||||
public record AddUserCommand(string Email, string Name, string Password);
|
||||
@@ -0,0 +1,30 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Randall.Application.Admin.AddUser;
|
||||
using Randall.Application.Admin.ApproveUser;
|
||||
using Randall.Application.Admin.DeleteUser;
|
||||
using Randall.Application.Admin.GetAllUsers;
|
||||
@@ -22,6 +23,7 @@ public static class DependencyInjection
|
||||
{
|
||||
services.AddScoped<RegisterHandler>();
|
||||
services.AddScoped<LoginHandler>();
|
||||
services.AddScoped<AddUserHandler>();
|
||||
services.AddScoped<GetAllUsersHandler>();
|
||||
services.AddScoped<GetPendingUsersHandler>();
|
||||
services.AddScoped<ApproveUserHandler>();
|
||||
|
||||
@@ -13,11 +13,16 @@ public class Reservation : Entity
|
||||
public ReservationStatus Status { get; private set; }
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
|
||||
// EF Core constructor
|
||||
private Reservation() : base()
|
||||
internal Reservation(
|
||||
Guid id, Guid workplaceId, string employeeEmail, string employeeName,
|
||||
DateOnly date, ReservationStatus status, DateTime createdAt) : base(id)
|
||||
{
|
||||
EmployeeEmail = string.Empty;
|
||||
EmployeeName = string.Empty;
|
||||
WorkplaceId = workplaceId;
|
||||
EmployeeEmail = employeeEmail;
|
||||
EmployeeName = employeeName;
|
||||
Date = date;
|
||||
Status = status;
|
||||
CreatedAt = createdAt;
|
||||
}
|
||||
|
||||
private Reservation(Guid workplaceId, string employeeEmail, string employeeName, DateOnly date)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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);
|
||||
}
|
||||
@@ -10,11 +10,13 @@ public class User : Entity
|
||||
public bool IsApproved { get; private set; }
|
||||
public bool IsAdmin { get; private set; }
|
||||
|
||||
private User() : base()
|
||||
internal User(Guid id, string email, string name, string passwordHash, bool isApproved, bool isAdmin) : base(id)
|
||||
{
|
||||
Email = string.Empty;
|
||||
Name = string.Empty;
|
||||
PasswordHash = string.Empty;
|
||||
Email = email;
|
||||
Name = name;
|
||||
PasswordHash = passwordHash;
|
||||
IsApproved = isApproved;
|
||||
IsAdmin = isAdmin;
|
||||
}
|
||||
|
||||
private User(string email, string name, string passwordHash, bool isAdmin) : base()
|
||||
|
||||
7
src/backend/src/Randall.Domain/Users/UserFactory.cs
Normal file
7
src/backend/src/Randall.Domain/Users/UserFactory.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
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);
|
||||
}
|
||||
@@ -8,10 +8,11 @@ public class Workplace : Entity
|
||||
public string Location { get; private set; }
|
||||
public bool IsActive { get; private set; }
|
||||
|
||||
private Workplace() : base()
|
||||
internal Workplace(Guid id, string name, string location, bool isActive) : base(id)
|
||||
{
|
||||
Name = string.Empty;
|
||||
Location = string.Empty;
|
||||
Name = name;
|
||||
Location = location;
|
||||
IsActive = isActive;
|
||||
}
|
||||
|
||||
public Workplace(string name, string location) : base()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
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);
|
||||
}
|
||||
120
src/backend/src/Randall.Infrastructure/Migrations/20260327154255_SeparateStorageModels.Designer.cs
generated
Normal file
120
src/backend/src/Randall.Infrastructure/Migrations/20260327154255_SeparateStorageModels.Designer.cs
generated
Normal file
@@ -0,0 +1,120 @@
|
||||
// <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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace Randall.Infrastructure.Migrations
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Reservations.Reservation", b =>
|
||||
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.ReservationRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -51,10 +51,10 @@ namespace Randall.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("WorkplaceId", "Date");
|
||||
|
||||
b.ToTable("Reservations");
|
||||
b.ToTable("Reservations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Users.User", b =>
|
||||
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.UserRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -85,10 +85,10 @@ namespace Randall.Infrastructure.Migrations
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
b.ToTable("Users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Randall.Domain.Workplaces.Workplace", b =>
|
||||
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.WorkplaceRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -109,7 +109,7 @@ namespace Randall.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Workplaces");
|
||||
b.ToTable("Workplaces", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Randall.Domain.Reservations;
|
||||
using Randall.Domain.Users;
|
||||
using Randall.Domain.Workplaces;
|
||||
using Randall.Infrastructure.Persistence.Records;
|
||||
|
||||
namespace Randall.Infrastructure.Persistence;
|
||||
|
||||
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<Workplace> Workplaces => Set<Workplace>();
|
||||
public DbSet<Reservation> Reservations => Set<Reservation>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<WorkplaceRecord> Workplaces => Set<WorkplaceRecord>();
|
||||
public DbSet<ReservationRecord> Reservations => Set<ReservationRecord>();
|
||||
public DbSet<UserRecord> Users => Set<UserRecord>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Workplace>(entity =>
|
||||
modelBuilder.Entity<WorkplaceRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("Workplaces");
|
||||
entity.HasKey(w => w.Id);
|
||||
entity.Property(w => w.Name).IsRequired().HasMaxLength(100);
|
||||
entity.Property(w => w.Location).IsRequired().HasMaxLength(200);
|
||||
entity.Property(w => w.IsActive).IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Reservation>(entity =>
|
||||
modelBuilder.Entity<ReservationRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("Reservations");
|
||||
entity.HasKey(r => r.Id);
|
||||
entity.Property(r => r.WorkplaceId).IsRequired();
|
||||
entity.Property(r => r.EmployeeEmail).IsRequired().HasMaxLength(200);
|
||||
@@ -35,8 +35,9 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
entity.HasIndex(r => new { r.EmployeeEmail, r.Date });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<User>(entity =>
|
||||
modelBuilder.Entity<UserRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("Users");
|
||||
entity.HasKey(u => u.Id);
|
||||
entity.Property(u => u.Email).IsRequired().HasMaxLength(200);
|
||||
entity.Property(u => u.Name).IsRequired().HasMaxLength(200);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,23 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Randall.Domain.Reservations;
|
||||
using Randall.Infrastructure.Persistence.Mappers;
|
||||
using Randall.Infrastructure.Persistence.Records;
|
||||
|
||||
namespace Randall.Infrastructure.Persistence.Reservations;
|
||||
|
||||
public class ReservationRepository(AppDbContext context) : IReservationRepository
|
||||
{
|
||||
public Task<Reservation?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
|
||||
context.Reservations.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
private readonly Dictionary<Guid, (Reservation Domain, ReservationRecord Record)> _tracked = [];
|
||||
|
||||
public async Task<IReadOnlyList<Reservation>> GetByEmployeeAsync(string employeeEmail, CancellationToken ct = default) =>
|
||||
await context.Reservations
|
||||
.Where(r => r.EmployeeEmail.ToLower() == employeeEmail.ToLower())
|
||||
public async Task<Reservation?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var record = await context.Reservations.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
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);
|
||||
return records.Select(ReservationMapper.ToDomain).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Reservation>> GetByWorkplaceAndDateAsync(
|
||||
Guid workplaceId, DateOnly date, CancellationToken ct = default) =>
|
||||
await context.Reservations
|
||||
Guid workplaceId, DateOnly date, CancellationToken ct = default)
|
||||
{
|
||||
var records = await context.Reservations
|
||||
.Where(r => r.WorkplaceId == workplaceId && r.Date == date)
|
||||
.ToListAsync(ct);
|
||||
return records.Select(ReservationMapper.ToDomain).ToList();
|
||||
}
|
||||
|
||||
public Task<bool> ExistsActiveForEmployeeOnDateAsync(
|
||||
string employeeEmail, DateOnly date, CancellationToken ct = default) =>
|
||||
@@ -36,14 +53,24 @@ public class ReservationRepository(AppDbContext context) : IReservationRepositor
|
||||
ct);
|
||||
|
||||
public async Task<IReadOnlyList<Reservation>> GetActiveReservationsForDateAsync(
|
||||
DateOnly date, CancellationToken ct = default) =>
|
||||
await context.Reservations
|
||||
DateOnly date, CancellationToken ct = default)
|
||||
{
|
||||
var records = await context.Reservations
|
||||
.Where(r => r.Date == date && r.Status == ReservationStatus.Active)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task AddAsync(Reservation reservation, CancellationToken ct = default) =>
|
||||
await context.Reservations.AddAsync(reservation, ct);
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken ct = default) =>
|
||||
context.SaveChangesAsync(ct);
|
||||
return records.Select(ReservationMapper.ToDomain).ToList();
|
||||
}
|
||||
|
||||
public async Task AddAsync(Reservation reservation, CancellationToken ct = default)
|
||||
{
|
||||
var record = ReservationMapper.ToRecord(reservation);
|
||||
await context.Reservations.AddAsync(record, ct);
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken ct = default)
|
||||
{
|
||||
foreach (var (domain, record) in _tracked.Values)
|
||||
ReservationMapper.SyncToRecord(domain, record);
|
||||
return context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Randall.Application.Common;
|
||||
using Randall.Domain.Users;
|
||||
using Randall.Domain.Workplaces;
|
||||
using Randall.Infrastructure.Persistence.Mappers;
|
||||
|
||||
namespace Randall.Infrastructure.Persistence.Seeding;
|
||||
|
||||
@@ -25,7 +26,7 @@ public static class DatabaseSeeder
|
||||
// Seed workplaces only if none exist yet
|
||||
if (!await context.Workplaces.AnyAsync())
|
||||
{
|
||||
var workplaces = ExpectedWorkplaces.Select(w => new Workplace(w.Name, w.Location));
|
||||
var workplaces = ExpectedWorkplaces.Select(w => WorkplaceMapper.ToRecord(new Workplace(w.Name, w.Location)));
|
||||
context.Workplaces.AddRange(workplaces);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
@@ -35,7 +36,7 @@ public static class DatabaseSeeder
|
||||
{
|
||||
var hash = passwordHasher.Hash(AdminPassword);
|
||||
var admin = User.Create(AdminEmail, "Admin", hash, isAdmin: true).Value!;
|
||||
context.Users.Add(admin);
|
||||
context.Users.Add(UserMapper.ToRecord(admin));
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,73 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Randall.Domain.Users;
|
||||
using Randall.Infrastructure.Persistence.Mappers;
|
||||
using Randall.Infrastructure.Persistence.Records;
|
||||
|
||||
namespace Randall.Infrastructure.Persistence.Users;
|
||||
|
||||
public class UserRepository(AppDbContext context) : IUserRepository
|
||||
{
|
||||
public Task<User?> GetByEmailAsync(string email, CancellationToken ct = default) =>
|
||||
context.Users.FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), ct);
|
||||
private readonly Dictionary<Guid, (User Domain, UserRecord Record)> _tracked = [];
|
||||
|
||||
public Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
|
||||
context.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||
private User Track(UserRecord record)
|
||||
{
|
||||
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) =>
|
||||
context.Users.AnyAsync(u => u.Email == email.ToLowerInvariant(), ct);
|
||||
|
||||
public Task<List<User>> GetPendingAsync(CancellationToken ct = default) =>
|
||||
context.Users.Where(u => !u.IsApproved && !u.IsAdmin).ToListAsync(ct);
|
||||
public async Task<List<User>> GetPendingAsync(CancellationToken ct = default)
|
||||
{
|
||||
var records = await context.Users.Where(u => !u.IsApproved && !u.IsAdmin).ToListAsync(ct);
|
||||
return records.Select(UserMapper.ToDomain).ToList();
|
||||
}
|
||||
|
||||
public Task<List<User>> GetAllAsync(CancellationToken ct = default) =>
|
||||
context.Users.OrderBy(u => u.Name).ToListAsync(ct);
|
||||
public async Task<List<User>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
var records = await context.Users.OrderBy(u => u.Name).ToListAsync(ct);
|
||||
return records.Select(UserMapper.ToDomain).ToList();
|
||||
}
|
||||
|
||||
public Task<int> CountAdminsAsync(CancellationToken ct = default) =>
|
||||
context.Users.CountAsync(u => u.IsAdmin);
|
||||
|
||||
public Task<List<User>> GetAllNonAdminAsync(CancellationToken ct = default) =>
|
||||
context.Users.Where(u => !u.IsAdmin).OrderBy(u => u.Name).ToListAsync(ct);
|
||||
|
||||
public async Task AddAsync(User user, CancellationToken ct = default) =>
|
||||
await context.Users.AddAsync(user, ct);
|
||||
|
||||
public void Delete(User user) =>
|
||||
context.Users.Remove(user);
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken ct = default) =>
|
||||
context.SaveChangesAsync(ct);
|
||||
public async Task<List<User>> GetAllNonAdminAsync(CancellationToken ct = default)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var record = UserMapper.ToRecord(user);
|
||||
await context.Users.AddAsync(record, ct);
|
||||
}
|
||||
|
||||
public void Delete(User user)
|
||||
{
|
||||
if (_tracked.TryGetValue(user.Id, out var entry))
|
||||
context.Users.Remove(entry.Record);
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken ct = default)
|
||||
{
|
||||
foreach (var (domain, record) in _tracked.Values)
|
||||
UserMapper.SyncToRecord(domain, record);
|
||||
return context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Randall.Domain.Workplaces;
|
||||
using Randall.Infrastructure.Persistence.Mappers;
|
||||
|
||||
namespace Randall.Infrastructure.Persistence.Workplaces;
|
||||
|
||||
public class WorkplaceRepository(AppDbContext context) : IWorkplaceRepository
|
||||
{
|
||||
public Task<Workplace?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
|
||||
context.Workplaces.FirstOrDefaultAsync(w => w.Id == id, ct);
|
||||
|
||||
public async Task<IReadOnlyList<Workplace>> GetAllActiveAsync(CancellationToken ct = default) =>
|
||||
await context.Workplaces.Where(w => w.IsActive).ToListAsync(ct);
|
||||
public async Task<Workplace?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var records = await context.Workplaces.Where(w => w.IsActive).ToListAsync(ct);
|
||||
return records.Select(WorkplaceMapper.ToDomain).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,4 +146,85 @@ public class AdminTests(CustomWebApplicationFactory factory) : IClassFixture<Cus
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Randall.Api.IntegrationTests;
|
||||
@@ -28,6 +29,9 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing && File.Exists(_dbPath))
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
File.Delete(_dbPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<title>randall</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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function App() {
|
||||
<Route path="/" element={<PlannerPage auth={auth} onLogout={handleLogout} />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={auth.isAdmin ? <AdminPage /> : <Navigate to="/" replace />}
|
||||
element={auth.isAdmin ? <AdminPage onLogout={handleLogout} /> : <Navigate to="/" replace />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AdminUser, AuthResponse, CreateReservationRequest, PendingUser, RegisterPendingResponse, Reservation, WorkplaceScheduleItem } from './types';
|
||||
import type { AddUserRequest, AdminUser, AuthResponse, CreateReservationRequest, PendingUser, RegisterPendingResponse, Reservation, WorkplaceScheduleItem } from './types';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
@@ -38,6 +38,15 @@ export const api = {
|
||||
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[]> {
|
||||
return fetch(`${BASE}/admin/users`, {
|
||||
headers: authHeaders(),
|
||||
|
||||
@@ -59,3 +59,9 @@ export interface AdminUser {
|
||||
isApproved: boolean;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface AddUserRequest {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ interface CancelModalProps {
|
||||
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) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -24,30 +30,77 @@ export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalP
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md mx-4"
|
||||
style={{
|
||||
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()}
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-1">Cancel reservation</h2>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
Cancel your booking for <span className="font-medium text-slate-700">{deskName}</span> on{' '}
|
||||
<span className="font-medium text-slate-700">{date}</span>?
|
||||
</p>
|
||||
<div style={{
|
||||
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
|
||||
color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500,
|
||||
}}>
|
||||
Cancel reservation
|
||||
</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 && <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg mb-4">{error}</p>}
|
||||
{error && (
|
||||
<div style={{
|
||||
fontSize: 13, color: '#c0392b',
|
||||
background: 'rgba(192,57,43,0.08)',
|
||||
borderRadius: 8, padding: '10px 14px',
|
||||
marginBottom: 18,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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"
|
||||
style={{
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
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"
|
||||
style={{
|
||||
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'}
|
||||
</button>
|
||||
|
||||
@@ -1,72 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface DeskProps {
|
||||
name: string;
|
||||
available: boolean;
|
||||
reserved: boolean; // reserved by the current user
|
||||
reservedBy?: string; // name of whoever reserved it (when taken by someone else)
|
||||
reserved: boolean;
|
||||
reservedBy?: string;
|
||||
rotate?: 'cw' | 'ccw';
|
||||
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) {
|
||||
let bgColor: string;
|
||||
let borderColor: string;
|
||||
let textColor: string;
|
||||
let deskColor: string;
|
||||
let cursor: string;
|
||||
let title: string;
|
||||
const [hover, setHover] = useState(false);
|
||||
|
||||
if (reserved) {
|
||||
bgColor = 'bg-blue-50';
|
||||
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 isMine = reserved;
|
||||
const isFree = !reserved && available;
|
||||
const isTaken = !reserved && !available;
|
||||
|
||||
// Truncate long names to fit the tile
|
||||
const displayName = reservedBy && reservedBy.length > 7
|
||||
const svgFill = isMine ? PURPLE_DEEP : isFree ? PURPLE : 'rgba(91,79,199,0.25)';
|
||||
|
||||
const displayLabel = isFree
|
||||
? 'free'
|
||||
: isMine
|
||||
? 'yours'
|
||||
: reservedBy
|
||||
? reservedBy.length > 7
|
||||
? reservedBy.slice(0, 6) + '…'
|
||||
: reservedBy;
|
||||
: reservedBy
|
||||
: 'taken';
|
||||
|
||||
return (
|
||||
<button
|
||||
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}`}
|
||||
onClick={available || reserved ? onClick : undefined}
|
||||
title={title}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
onClick={isTaken ? undefined : onClick}
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{/* Top-down desk: rectangular surface + screen stripe */}
|
||||
<svg viewBox="0 0 48 48" className={`w-10 h-10 ${rotate === 'cw' ? 'rotate-90' : rotate === 'ccw' ? '-rotate-90' : ''}`} fill="none" aria-hidden="true">
|
||||
{/* Desk surface (wide rectangle, ~1.6:1 ratio) */}
|
||||
<rect x="2" y="10" width="44" height="28" rx="2.5" fill={deskColor} />
|
||||
{/* Screen stripe — centered, ~30% of desk width, small margin from back edge */}
|
||||
<rect x="17" y="13" width="14" height="5" rx="1.5" fill="#1e293b" />
|
||||
{/* Chair — small square in front of desk */}
|
||||
<rect x="18" y="41" width="12" height="8" rx="2" fill={deskColor} />
|
||||
<svg
|
||||
viewBox="0 0 48 48"
|
||||
width={28}
|
||||
height={28}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
transform: rotate === 'cw' ? 'rotate(90deg)' : 'rotate(-90deg)',
|
||||
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>
|
||||
|
||||
<span className="text-xs font-semibold leading-none">{name}</span>
|
||||
{reserved && <span className="text-[10px] opacity-75 leading-none">Mine</span>}
|
||||
{available && <span className="text-[10px] opacity-75 leading-none">Free</span>}
|
||||
{!reserved && !available && (
|
||||
<span className="text-[10px] opacity-75 leading-tight text-center px-0.5">
|
||||
{displayName ?? 'Taken'}
|
||||
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: '-0.01em', lineHeight: 1 }}>
|
||||
{name}
|
||||
</span>
|
||||
<span style={{ fontSize: 9, opacity: 0.7, fontWeight: 500, lineHeight: 1.1, textAlign: 'center' }}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,20 +9,39 @@ interface ScheduleItem {
|
||||
}
|
||||
|
||||
interface DeskPodProps {
|
||||
label: string;
|
||||
desks: ScheduleItem[];
|
||||
myReservedIds: Set<string>;
|
||||
onDeskClick: (desk: ScheduleItem) => void;
|
||||
}
|
||||
|
||||
export function DeskPod({ desks, myReservedIds, onDeskClick }: DeskPodProps) {
|
||||
export function DeskPod({ label, desks, myReservedIds, onDeskClick }: DeskPodProps) {
|
||||
const left = [...desks.slice(0, 4)].reverse();
|
||||
const right = [...desks.slice(4, 8)].reverse();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="bg-white border-2 border-slate-200 rounded-2xl p-5 shadow-sm">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, flex: '0 0 auto' }}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
letterSpacing: '0.18em',
|
||||
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) => (
|
||||
<Desk
|
||||
key={desk.id}
|
||||
@@ -35,10 +54,8 @@ export function DeskPod({ desks, myReservedIds, onDeskClick }: DeskPodProps) {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-px bg-slate-100" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div style={{ width: 1, background: 'rgba(91,79,199,0.10)', alignSelf: 'stretch' }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{right.map((desk) => (
|
||||
<Desk
|
||||
key={desk.id}
|
||||
@@ -53,6 +70,5 @@ export function DeskPod({ desks, myReservedIds, onDeskClick }: DeskPodProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ interface MyReservationsProps {
|
||||
onCancel: (reservation: Reservation) => void;
|
||||
}
|
||||
|
||||
const PURPLE = '#5b4fc7';
|
||||
const PURPLE_DEEP = '#3f33a8';
|
||||
const SAGE = '#c7d4b8';
|
||||
const SAGE_DEEP = '#a9bb96';
|
||||
|
||||
export function MyReservations({ reservations, onCancel }: MyReservationsProps) {
|
||||
const upcoming = reservations
|
||||
.filter((r) => r.status === 'Active')
|
||||
@@ -12,24 +17,46 @@ export function MyReservations({ reservations, onCancel }: MyReservationsProps)
|
||||
|
||||
if (upcoming.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-slate-400 text-center py-4">No upcoming reservations.</p>
|
||||
<p style={{ fontSize: 13, color: PURPLE, opacity: 0.4, textAlign: 'center', padding: '8px 0', margin: 0 }}>
|
||||
No upcoming reservations.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-2">
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{upcoming.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-4 py-3"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 0',
|
||||
borderBottom: '1px solid rgba(91,79,199,0.08)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium text-slate-800">{r.workplaceName}</span>
|
||||
<div className="text-xs text-slate-400 mt-0.5">{r.date}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: PURPLE, lineHeight: 1 }}>
|
||||
{r.workplaceName}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: PURPLE, opacity: 0.55, marginTop: 3 }}>
|
||||
{r.date}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onCancel(r)}
|
||||
className="text-xs text-red-500 hover:text-red-700 font-medium px-3 py-1 rounded-lg hover:bg-red-50 transition-colors"
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
borderRadius: 99,
|
||||
background: SAGE,
|
||||
border: `1px solid ${SAGE_DEEP}`,
|
||||
color: PURPLE_DEEP,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -7,6 +7,12 @@ interface ReservationModalProps {
|
||||
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) {
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -24,29 +30,77 @@ export function ReservationModal({ deskName, date, onConfirm, onClose }: Reserva
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-sm mx-4"
|
||||
style={{
|
||||
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()}
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-1">Reserve desk</h2>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
<span className="font-medium text-slate-700">{deskName}</span> — {date}
|
||||
</p>
|
||||
<div style={{
|
||||
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
|
||||
color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500,
|
||||
}}>
|
||||
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 && <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg mb-4">{error}</p>}
|
||||
{error && (
|
||||
<div style={{
|
||||
fontSize: 13, color: '#c0392b',
|
||||
background: 'rgba(192,57,43,0.08)',
|
||||
borderRadius: 8, padding: '10px 14px',
|
||||
marginBottom: 18,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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"
|
||||
style={{
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
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"
|
||||
style={{
|
||||
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'}
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg: #e6e7e0;
|
||||
--paper: #f4f3ee;
|
||||
--ink: #2a1f6b;
|
||||
--purple: #5b4fc7;
|
||||
--purple-deep: #3f33a8;
|
||||
--sage: #c7d4b8;
|
||||
--sage-deep: #a9bb96;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--purple);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -3,16 +3,35 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import type { AdminUser } from '../api/types';
|
||||
|
||||
export function AdminPage() {
|
||||
const PURPLE = '#5b4fc7';
|
||||
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 [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [approvingId, setApprovingId] = useState<string | null>(null);
|
||||
const [makingAdminId, setMakingAdminId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [confirmAdminId, setConfirmAdminId] = 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() {
|
||||
setLoading(true);
|
||||
@@ -44,7 +63,7 @@ export function AdminPage() {
|
||||
setMakingAdminId(id);
|
||||
try {
|
||||
await api.makeAdmin(id);
|
||||
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||||
setUsers((prev) => prev.map((u) => u.id === id ? { ...u, isAdmin: true } : u));
|
||||
setConfirmAdminId(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to make user admin');
|
||||
@@ -66,147 +85,472 @@ export function AdminPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const pending = users.filter((u) => !u.isApproved);
|
||||
const approved = users.filter((u) => u.isApproved);
|
||||
|
||||
function UserRow({ user }: { user: AdminUser }) {
|
||||
const busy = approvingId === user.id || makingAdminId === user.id || deletingId === user.id;
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-5 py-4 shadow-sm">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-slate-800">{user.name}</p>
|
||||
{user.isAdmin && (
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 text-amber-700">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">{user.email}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{!user.isApproved && (
|
||||
<button
|
||||
onClick={() => handleApprove(user.id)}
|
||||
disabled={busy || confirmAdminId === user.id || confirmDeleteId === user.id}
|
||||
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"
|
||||
>
|
||||
{approvingId === user.id ? '…' : 'Approve'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!user.isAdmin && confirmAdminId === user.id ? (
|
||||
<>
|
||||
<span className="text-sm text-slate-500">Make admin?</span>
|
||||
<button
|
||||
onClick={() => handleMakeAdmin(user.id)}
|
||||
disabled={makingAdminId === 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"
|
||||
>
|
||||
{makingAdminId === user.id ? '…' : 'Yes'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAdminId(null)}
|
||||
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
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>
|
||||
)
|
||||
)}
|
||||
|
||||
{confirmDeleteId === user.id ? (
|
||||
<>
|
||||
<span className="text-sm text-slate-500">Sure?</span>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
disabled={deletingId === user.id}
|
||||
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"
|
||||
>
|
||||
{deletingId === user.id ? '…' : 'Yes, delete'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(user.id)}
|
||||
disabled={busy || confirmAdminId === user.id}
|
||||
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"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
async function handleAddUser(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
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) => {
|
||||
const q = search.toLowerCase();
|
||||
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 (
|
||||
<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
|
||||
<div style={{ background: 'var(--bg)', minHeight: '100vh' }}>
|
||||
<div style={{
|
||||
maxWidth: 1100,
|
||||
margin: '0 auto',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
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('/')}
|
||||
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
color: PURPLE,
|
||||
fontFamily: "'Rubik Mono One', monospace",
|
||||
fontSize: 18, letterSpacing: '-0.02em', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
← Back to planner
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 28, fontSize: 13, color: PURPLE }}>
|
||||
<span style={{ fontWeight: 500 }}>Users</span>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
style={{
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>}
|
||||
{/* 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 && (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
{/* Users table card */}
|
||||
<div style={{
|
||||
background: PAPER, borderRadius: 18,
|
||||
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
|
||||
onClick={() => { setAddForm({ name: '', email: '', password: '' }); setAddError(''); setShowAddModal(true); }}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 99,
|
||||
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
|
||||
color: PURPLE_DEEP, fontSize: 12, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
+ Add user
|
||||
</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>
|
||||
)}
|
||||
</section>
|
||||
{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
|
||||
onClick={() => handleApprove(u.id)}
|
||||
disabled={busy}
|
||||
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'}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{confirmAdminId === u.id ? (
|
||||
<>
|
||||
<span style={{ fontSize: 12, color: PURPLE, opacity: 0.7 }}>Make admin?</span>
|
||||
<button
|
||||
onClick={() => handleMakeAdmin(u.id)}
|
||||
disabled={!!makingAdminId}
|
||||
style={{
|
||||
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'}
|
||||
</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
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
style={{
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addSubmitting}
|
||||
style={{
|
||||
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'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,34 @@ interface AuthPageProps {
|
||||
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) {
|
||||
const [mode, setMode] = useState<'login' | 'register' | 'pending'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -14,7 +42,7 @@ export function AuthPage({ onAuth }: AuthPageProps) {
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: { preventDefault: () => void }) {
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
@@ -34,101 +62,220 @@ export function AuthPage({ onAuth }: AuthPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-slate-800">Office Planner</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">Reserve your workspace</p>
|
||||
<div style={{ background: 'var(--bg)', minHeight: '100vh', display: 'flex' }}>
|
||||
<div style={{
|
||||
maxWidth: 1100,
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
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>
|
||||
|
||||
{mode === 'pending' && (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8 text-center">
|
||||
<div className="text-3xl mb-4">⏳</div>
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-2">Account pending approval</h2>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
{/* Left editorial column */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
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.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setMode('login'); setError(''); }}
|
||||
className="text-sm text-emerald-600 hover:text-emerald-700 font-medium"
|
||||
style={{
|
||||
background: 'none', border: 'none', padding: 0,
|
||||
fontSize: 13, color: PURPLE, opacity: 0.7, cursor: 'pointer',
|
||||
fontFamily: 'inherit', textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode !== 'pending' && <div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||
<div className="flex rounded-lg bg-slate-100 p-1 mb-6">
|
||||
) : (
|
||||
<div style={{
|
||||
width: '100%', background: PAPER, borderRadius: 18, padding: 28,
|
||||
border: '1px solid rgba(91,79,199,0.10)',
|
||||
}}>
|
||||
{/* 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
|
||||
onClick={() => { setMode('login'); setError(''); }}
|
||||
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
mode === 'login' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
key={m}
|
||||
onClick={() => { setMode(m); setError(''); }}
|
||||
style={{
|
||||
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',
|
||||
}}
|
||||
>
|
||||
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
|
||||
{m === 'login' ? 'Sign in' : 'Register'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{mode === 'register' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Full name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Work email</label>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<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)}
|
||||
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"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Password</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)}
|
||||
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"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{mode === 'register' && (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<span style={fieldLabelStyle}>Full name</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Jane Smith"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||
<div style={{
|
||||
fontSize: 13, color: '#c0392b',
|
||||
background: 'rgba(192,57,43,0.08)',
|
||||
borderRadius: 8, padding: '10px 14px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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"
|
||||
style={{
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,12 @@ import { ReservationModal } from '../components/ReservationModal';
|
||||
import { CancelModal } from '../components/CancelModal';
|
||||
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 {
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
@@ -25,6 +31,49 @@ 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 {
|
||||
auth: AuthResponse;
|
||||
onLogout: () => void;
|
||||
@@ -34,7 +83,7 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const today = toIsoDate(new Date());
|
||||
const maxDate = toIsoDate(new Date(Date.now() + 14 * 24 * 60 * 60 * 1000));
|
||||
const MAX_OFFSET = 13;
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState(today);
|
||||
const [schedule, setSchedule] = useState<WorkplaceScheduleItem[]>([]);
|
||||
@@ -65,6 +114,30 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
|
||||
.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) {
|
||||
if (myReservedIdsOnDate.has(desk.id)) {
|
||||
const res = myReservations.find(
|
||||
@@ -93,93 +166,380 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
|
||||
const podA = schedule.filter((w) => w.location === 'Pod A');
|
||||
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 (
|
||||
<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-5xl mx-auto flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-800">Office Planner</h1>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Reserve your workspace up to 2 weeks ahead</p>
|
||||
/* Outer bg fills the full viewport */
|
||||
<div style={{ background: 'var(--bg)', minHeight: '100vh' }}>
|
||||
{/* Centred 1100 px frame */}
|
||||
<div style={{
|
||||
maxWidth: 1100,
|
||||
margin: '0 auto',
|
||||
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 className="flex items-center gap-4">
|
||||
<span className="text-sm text-slate-600">{auth.name}</span>
|
||||
|
||||
{/* Nav */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 28, fontSize: 13, color: PURPLE }}>
|
||||
<span style={{ fontWeight: 500 }}>Today</span>
|
||||
{auth.isAdmin && (
|
||||
<button
|
||||
onClick={() => navigate('/admin')}
|
||||
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
|
||||
style={{
|
||||
background: 'none', border: 'none', fontFamily: 'inherit',
|
||||
fontSize: 13, color: PURPLE, opacity: 0.6, cursor: 'pointer', padding: 0,
|
||||
}}
|
||||
>
|
||||
Admin portal
|
||||
Admin
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
|
||||
style={{
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: PURPLE, opacity: 0.6, cursor: 'default' }}>NL ▾</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 flex flex-col gap-8">
|
||||
<section className="flex items-center gap-3 flex-wrap">
|
||||
<label className="text-sm font-medium text-slate-600 whitespace-nowrap">Date</label>
|
||||
{/* Body */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
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
|
||||
onClick={() => setSelectedDate(offsetDate(selectedDate, -1))}
|
||||
disabled={selectedDate <= today}
|
||||
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"
|
||||
disabled={dayOffset === 0}
|
||||
style={arrowBtnStyle(dayOffset === 0)}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
min={today}
|
||||
max={maxDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 5, flex: 1 }}>
|
||||
{dayStrip.map((dateIso) => {
|
||||
const isActive = dateIso === selectedDate;
|
||||
const { weekday, day, monthTag } = formatDayCell(dateIso);
|
||||
return (
|
||||
<button
|
||||
key={dateIso}
|
||||
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
|
||||
onClick={() => setSelectedDate(offsetDate(selectedDate, 1))}
|
||||
disabled={selectedDate >= maxDate}
|
||||
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"
|
||||
disabled={dayOffset >= MAX_OFFSET}
|
||||
style={arrowBtnStyle(dayOffset >= MAX_OFFSET)}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
<span className="text-sm text-slate-500 font-medium">{formatDisplayDate(selectedDate)}</span>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 rounded bg-emerald-400 inline-block" />
|
||||
Available — click to reserve
|
||||
{/* Floor card */}
|
||||
<div style={{
|
||||
background: PAPER,
|
||||
borderRadius: 18,
|
||||
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 className="flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 rounded bg-blue-400 inline-block" />
|
||||
Your reservation — click to cancel
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: 2,
|
||||
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
|
||||
display: 'inline-block', flexShrink: 0,
|
||||
}} />
|
||||
yours
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 rounded bg-slate-300 inline-block" />
|
||||
Taken — hover to see who
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: 2,
|
||||
border: '1px dashed rgba(91,79,199,0.4)',
|
||||
display: 'inline-block', flexShrink: 0,
|
||||
}} />
|
||||
taken
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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>}
|
||||
{/* Pods */}
|
||||
{!loadingFloor && !floorError && (
|
||||
<div className="flex flex-wrap gap-12 justify-center">
|
||||
<DeskPod desks={podA} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
|
||||
<DeskPod desks={podB} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 36,
|
||||
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>
|
||||
)}
|
||||
</section>
|
||||
{loadingFloor && (
|
||||
<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>
|
||||
|
||||
<section className="bg-white border border-slate-200 rounded-2xl p-6 shadow-sm">
|
||||
<h2 className="text-base font-semibold text-slate-700 mb-4">My reservations</h2>
|
||||
{/* Right rail */}
|
||||
<div style={{ width: 300, display: 'flex', flexDirection: 'column', gap: 14, overflow: 'auto', flexShrink: 0 }}>
|
||||
{/* 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} />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reserveTarget && (
|
||||
<ReservationModal
|
||||
|
||||
@@ -7,7 +7,7 @@ export default defineConfig({
|
||||
retries: 0,
|
||||
reporter: [['list'], ['html', { open: 'never' }]],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL ?? 'http://localhost',
|
||||
baseURL: process.env.BASE_URL ?? 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
headless: !!process.env.CI,
|
||||
|
||||
@@ -6,41 +6,96 @@ test.describe('Admin portal', () => {
|
||||
await loginAsAdmin(page);
|
||||
});
|
||||
|
||||
test('admin user sees the Admin portal button in the header', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: 'Admin portal' })).toBeVisible();
|
||||
test('admin user sees the Admin button in the header', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: 'Admin' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('navigates to the admin portal', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Admin portal' }).click();
|
||||
await page.getByRole('button', { name: 'Admin' }).click();
|
||||
await page.waitForURL('/admin');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Admin Portal' })).toBeVisible();
|
||||
await expect(page.getByText('Manage user accounts')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /Who can book/i })).toBeVisible();
|
||||
await expect(page.getByText(/Users.*The Hague HQ/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin portal lists the admin account under Approved accounts', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
test('admin portal lists the admin account', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Admin' }).click();
|
||||
await page.waitForURL('/admin');
|
||||
|
||||
await expect(page.getByText('Approved accounts')).toBeVisible();
|
||||
// Target the name paragraph inside the admin's list item
|
||||
const adminRow = page.locator('li').filter({ hasText: 'admin@randall.local' });
|
||||
await expect(adminRow.getByRole('paragraph').filter({ hasText: /^Admin$/ })).toBeVisible();
|
||||
await expect(page.getByText('admin@randall.local')).toBeVisible();
|
||||
const adminRow = page.locator('[data-testid="user-row"]').filter({ hasText: 'admin@randall.local' });
|
||||
await expect(adminRow.locator('[data-testid="role-badge"]').filter({ hasText: /^admin$/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin account has the Admin badge and no Make admin button', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
test('admin account has the admin role badge and no make-admin button', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Admin' }).click();
|
||||
await page.waitForURL('/admin');
|
||||
|
||||
const adminRow = page.locator('li').filter({ hasText: 'admin@randall.local' });
|
||||
// The badge is a <span> with class bg-amber-100
|
||||
await expect(adminRow.locator('span').filter({ hasText: /^Admin$/ })).toBeVisible();
|
||||
await expect(adminRow.getByRole('button', { name: 'Make admin' })).not.toBeVisible();
|
||||
const adminRow = page.locator('[data-testid="user-row"]').filter({ hasText: 'admin@randall.local' });
|
||||
await expect(adminRow.locator('[data-testid="role-badge"]').filter({ hasText: /^admin$/i })).toBeVisible();
|
||||
// The make-admin action button is not rendered for users who are already admin
|
||||
await expect(adminRow.getByRole('button', { name: 'Admin' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Back to planner link returns to the planner', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('button', { name: '← Back to planner' }).click();
|
||||
test('wordmark navigates back to the planner', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Admin' }).click();
|
||||
await page.waitForURL('/admin');
|
||||
await page.locator('span', { hasText: 'randall' }).first().click();
|
||||
|
||||
await page.waitForURL('/');
|
||||
await expect(page.getByText('Reserve your workspace up to 2 weeks ahead')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /Where to sit/i })).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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,22 +7,22 @@ test.describe('Authentication', () => {
|
||||
});
|
||||
|
||||
test('shows the sign-in form on initial load', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'Office Planner' })).toBeVisible();
|
||||
await expect(page.getByPlaceholder('jane@company.com')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /Find your desk/i })).toBeVisible();
|
||||
await expect(page.getByPlaceholder('you@randall.local')).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 }) => {
|
||||
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
|
||||
await page.getByPlaceholder('you@randall.local').fill(ADMIN_EMAIL);
|
||||
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
await expect(page.getByText('Reserve your workspace up to 2 weeks ahead')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /Where to sit/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows an error for an unknown email', async ({ page }) => {
|
||||
await page.getByPlaceholder('jane@company.com').fill('nobody@example.com');
|
||||
await page.getByPlaceholder('you@randall.local').fill('nobody@example.com');
|
||||
await page.getByPlaceholder('••••••••').fill('anything');
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
@@ -30,7 +30,7 @@ test.describe('Authentication', () => {
|
||||
});
|
||||
|
||||
test('shows an error for a wrong password', async ({ page }) => {
|
||||
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
|
||||
await page.getByPlaceholder('you@randall.local').fill(ADMIN_EMAIL);
|
||||
await page.getByPlaceholder('••••••••').fill('wrongpassword');
|
||||
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 }) => {
|
||||
// Switch to register mode
|
||||
await page.locator('button').filter({ hasText: 'Create account' }).first().click();
|
||||
await page.locator('button').filter({ hasText: 'Register' }).first().click();
|
||||
|
||||
await page.getByPlaceholder('Jane Smith').fill('Test User');
|
||||
await page.getByPlaceholder('jane@company.com').fill(`testuser+${Date.now()}@example.com`);
|
||||
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.locator('button[type="submit"]').click();
|
||||
|
||||
await expect(page.getByText('Account pending approval')).toBeVisible();
|
||||
await expect(page.getByText('Almost there.')).toBeVisible();
|
||||
await expect(page.getByText(/administrator will review/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('signs out and returns to the sign-in form', async ({ page }) => {
|
||||
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
|
||||
await page.getByPlaceholder('you@randall.local').fill(ADMIN_EMAIL);
|
||||
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.waitForURL('/');
|
||||
|
||||
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 →');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,20 +6,25 @@ export const ADMIN_NAME = 'Admin';
|
||||
|
||||
export async function loginAs(page: Page, email: string, password: string) {
|
||||
await page.goto('/');
|
||||
await page.getByPlaceholder('jane@company.com').fill(email);
|
||||
await page.getByPlaceholder('you@randall.local').fill(email);
|
||||
await page.getByPlaceholder('••••••••').fill(password);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.waitForURL('/');
|
||||
await page.getByText('Reserve your workspace up to 2 weeks ahead').waitFor();
|
||||
await page.locator('h1').waitFor();
|
||||
}
|
||||
|
||||
export async function loginAsAdmin(page: Page) {
|
||||
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 {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAsAdmin, offsetDate } from './helpers';
|
||||
import { loginAsAdmin, offsetDate, selectDate } from './helpers';
|
||||
|
||||
test.describe('Planner', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -8,20 +8,24 @@ test.describe('Planner', () => {
|
||||
|
||||
test('displays both pods with 8 desks each', async ({ page }) => {
|
||||
// Wait for floor plan to load — at least one free desk must be visible
|
||||
await expect(page.getByRole('button', { name: 'D1 Free' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^D1 free$/i })).toBeVisible();
|
||||
|
||||
// All 16 desk buttons contain a desk label (D1–D16) 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);
|
||||
});
|
||||
|
||||
test('date picker is constrained to today and 14 days ahead', async ({ page }) => {
|
||||
const input = page.locator('input[type="date"]');
|
||||
const today = offsetDate(0);
|
||||
const max = offsetDate(14);
|
||||
test('date strip shows 14 days and respects boundaries', async ({ page }) => {
|
||||
// 14 day-strip cells are rendered (one per bookable day)
|
||||
await expect(page.locator('[data-date]')).toHaveCount(14);
|
||||
|
||||
await expect(input).toHaveAttribute('min', today);
|
||||
await expect(input).toHaveAttribute('max', max);
|
||||
// Today and the last bookable day are both present
|
||||
await expect(page.locator(`[data-date="${offsetDate(0)}"]`)).toBeVisible();
|
||||
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 }) => {
|
||||
@@ -29,72 +33,60 @@ test.describe('Planner', () => {
|
||||
});
|
||||
|
||||
test('next-day button is disabled when on the maximum date', async ({ page }) => {
|
||||
const input = page.locator('input[type="date"]');
|
||||
await input.fill(offsetDate(14));
|
||||
await input.dispatchEvent('change');
|
||||
await page.locator(`[data-date="${offsetDate(13)}"]`).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: '→' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('reserves a desk and shows it as Mine', async ({ page }) => {
|
||||
const targetDate = offsetDate(7);
|
||||
const input = page.locator('input[type="date"]');
|
||||
await input.fill(targetDate);
|
||||
await input.dispatchEvent('change');
|
||||
test('reserves a desk and shows it as yours', async ({ page }) => {
|
||||
await selectDate(page, 7);
|
||||
|
||||
await page.getByRole('button', { name: 'D1 Free' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Reserve desk' })).toBeVisible();
|
||||
await page.getByRole('button', { name: /^D1 free$/i }).click();
|
||||
await expect(page.getByRole('dialog').getByText('Reserve desk')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: /^D1\s+Mine/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^D1 yours$/i })).toBeVisible();
|
||||
|
||||
// Clean up
|
||||
await page.getByRole('button', { name: /^D1\s+Mine/ }).click();
|
||||
await page.getByRole('button', { name: /^D1 yours$/i }).click();
|
||||
await page.getByRole('button', { name: 'Cancel reservation' }).click();
|
||||
});
|
||||
|
||||
test('reserved desk appears in My reservations', async ({ page }) => {
|
||||
const targetDate = offsetDate(8);
|
||||
const input = page.locator('input[type="date"]');
|
||||
await input.fill(targetDate);
|
||||
await input.dispatchEvent('change');
|
||||
await selectDate(page, 8);
|
||||
|
||||
await page.getByRole('button', { name: 'D2 Free' }).click();
|
||||
await page.getByRole('button', { name: /D2.*free/i }).click();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'My reservations' })).toBeVisible();
|
||||
await expect(page.locator('section').filter({ hasText: 'My reservations' }).getByText('D2')).toBeVisible();
|
||||
await expect(page.getByText('My reservations')).toBeVisible();
|
||||
await expect(page.locator('li').filter({ hasText: 'D2' })).toBeVisible();
|
||||
|
||||
// Clean up
|
||||
await page.getByRole('button', { name: /^D2\s+Mine/ }).click();
|
||||
await page.getByRole('button', { name: /D2.*yours/i }).click();
|
||||
await page.getByRole('button', { name: 'Cancel reservation' }).click();
|
||||
});
|
||||
|
||||
test('cancels a reservation and desk returns to Free', async ({ page }) => {
|
||||
const targetDate = offsetDate(9);
|
||||
const input = page.locator('input[type="date"]');
|
||||
await input.fill(targetDate);
|
||||
await input.dispatchEvent('change');
|
||||
test('cancels a reservation and desk returns to free', async ({ page }) => {
|
||||
await selectDate(page, 9);
|
||||
|
||||
// Reserve
|
||||
await page.getByRole('button', { name: 'D3 Free' }).click();
|
||||
await page.getByRole('button', { name: /D3.*free/i }).click();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
await expect(page.getByRole('button', { name: /^D3\s+Mine/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /D3.*yours/i })).toBeVisible();
|
||||
|
||||
// Cancel
|
||||
await page.getByRole('button', { name: /^D3\s+Mine/ }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Cancel reservation' })).toBeVisible();
|
||||
await page.getByRole('button', { name: /D3.*yours/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Cancel reservation' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'D3 Free' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /D3.*free/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('dismisses the reservation modal when clicking Cancel', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'D4 Free' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Reserve desk' })).toBeVisible();
|
||||
await page.getByRole('button', { name: /D4.*free/i }).click();
|
||||
await expect(page.getByRole('dialog').getByText('Reserve desk')).toBeVisible();
|
||||
|
||||
// Scope to the modal overlay to avoid matching Cancel buttons in My reservations
|
||||
await page.locator('.fixed.inset-0').getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Reserve desk' })).not.toBeVisible();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user