Compare commits
1 Commits
main
...
claude/elo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20b6bb3d8f |
@@ -1,31 +0,0 @@
|
|||||||
# Git Commit
|
|
||||||
|
|
||||||
Look at all staged changes using `git diff --cached` and create a descriptive git commit message that follows these rules:
|
|
||||||
|
|
||||||
- Use conventional commit format: `type(scope): description`
|
|
||||||
- Types: feat, fix, docs, refactor, test, chore
|
|
||||||
- Keep the subject line under 72 characters
|
|
||||||
- Add a short body if the change is complex and needs explanation
|
|
||||||
- Be specific about what changed and why
|
|
||||||
|
|
||||||
Then run the commit with that message.
|
|
||||||
|
|
||||||
## Examples of good commit messages
|
|
||||||
- `feat(auth): add JWT token refresh endpoint`
|
|
||||||
- `fix(api): handle null response from payment gateway`
|
|
||||||
- `refactor(db): extract connection pooling into separate module`
|
|
||||||
- `docs(readme): add setup instructions for Windows`
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**How to run it**
|
|
||||||
|
|
||||||
In Claude Code:
|
|
||||||
```
|
|
||||||
/commit
|
|
||||||
```
|
|
||||||
|
|
||||||
Or with a hint:
|
|
||||||
```
|
|
||||||
/commit this fixes the login bug we discussed
|
|
||||||
18
.claude/settings.local.json
Normal file
18
.claude/settings.local.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(dotnet new:*)",
|
||||||
|
"Bash(dotnet sln:*)",
|
||||||
|
"Bash(dotnet add:*)",
|
||||||
|
"Bash(dotnet build:*)",
|
||||||
|
"Bash(dotnet tool:*)",
|
||||||
|
"Bash(dotnet ef:*)",
|
||||||
|
"Bash(timeout 8 dotnet run)",
|
||||||
|
"Bash(npm create:*)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"Bash(npx playwright:*)",
|
||||||
|
"Bash(dotnet test:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -53,8 +53,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
|
||||||
driver: docker
|
|
||||||
|
|
||||||
- name: Build images
|
- name: Build images
|
||||||
run: docker compose -f cicd/docker/docker-compose.yml build
|
run: docker compose -f cicd/docker/docker-compose.yml build
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -52,10 +52,3 @@ tests/e2e/.cache/
|
|||||||
# -------------------------
|
# -------------------------
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# Claude
|
|
||||||
# -------------------------
|
|
||||||
.claude/settings.local.json
|
|
||||||
.claude/cache/
|
|
||||||
.claude/sessions/
|
|
||||||
96
CLAUDE.md
Normal file
96
CLAUDE.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Randall** is an office desk reservation system. It uses a .NET 10 backend (Clean Architecture) with a React 19 + TypeScript frontend, served via Nginx reverse proxy in Docker.
|
||||||
|
|
||||||
|
Default admin: `admin@randall.local` / `Admin@123`
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
# Run (port 5180)
|
||||||
|
dotnet run --project src/backend/src/Randall.Api
|
||||||
|
|
||||||
|
# Unit tests
|
||||||
|
dotnet test src/backend/tests/unit/Randall.Domain.UnitTests
|
||||||
|
|
||||||
|
# Integration tests
|
||||||
|
dotnet test src/backend/tests/integration/Randall.Api.IntegrationTests
|
||||||
|
|
||||||
|
# All backend tests
|
||||||
|
dotnet test src/backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
cd src/frontend
|
||||||
|
npm run dev # Dev server (port 5173, proxies /api → localhost:5180)
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
```bash
|
||||||
|
cd tests/e2e
|
||||||
|
npx playwright test
|
||||||
|
npx playwright test --headed
|
||||||
|
npx playwright test --ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```bash
|
||||||
|
docker compose -f cicd/docker/docker-compose.yml up --build
|
||||||
|
# Runs on port 80; backend health check at :8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend — Clean Architecture (4 layers)
|
||||||
|
|
||||||
|
| Layer | Project | Role |
|
||||||
|
|-------|---------|------|
|
||||||
|
| API | `Randall.Api` | Controllers, JWT middleware, request/response |
|
||||||
|
| Application | `Randall.Application` | Use-case handlers, DTOs, DI registration |
|
||||||
|
| Domain | `Randall.Domain` | Entities, business rules, `Result<T>` pattern |
|
||||||
|
| Infrastructure | `Randall.Infrastructure` | EF Core, SQLite, JWT generation, password hashing |
|
||||||
|
|
||||||
|
**Key patterns:**
|
||||||
|
- **Result pattern:** `Result<T>` (railway-oriented error handling) used throughout domain and application layers.
|
||||||
|
- **Repository pattern:** interfaces in Domain, implementations in Infrastructure.
|
||||||
|
- **Handlers:** each use case has a dedicated handler class in Application (organized by feature: Admin, Auth, Reservations, Workplaces).
|
||||||
|
|
||||||
|
**Domain constraints:**
|
||||||
|
- One active reservation per employee per day
|
||||||
|
- One active reservation per workplace per day
|
||||||
|
|
||||||
|
**Database:** SQLite at `randall.db` (Docker: `/app/data/randall.db`). Migrations apply automatically on startup. Seeded with 16 workplaces + admin account on first run (skipped if DB is non-empty).
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- **Pages:** `AuthPage`, `PlannerPage`, `AdminPage` — routed via React Router
|
||||||
|
- **API client:** `src/api/client.ts` — centralized typed fetch wrapper
|
||||||
|
- **Auth state:** JWT stored in localStorage, passed as Bearer token
|
||||||
|
- **Dev proxy:** Vite proxies `/api` to backend, so no CORS issues locally
|
||||||
|
|
||||||
|
### Request Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
React SPA → Nginx (/api/* proxied) → ASP.NET Core Controller
|
||||||
|
→ Application Handler → Domain Entity → Infrastructure (EF Core / SQLite)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
|
||||||
|
| Layer | Framework | Approach |
|
||||||
|
|-------|-----------|----------|
|
||||||
|
| Unit | xUnit | Domain entities in isolation |
|
||||||
|
| Integration | xUnit + WebApplicationFactory | Full API endpoints with in-process SQLite |
|
||||||
|
| E2E | Playwright | Full user journeys through browser |
|
||||||
|
|
||||||
|
### CI/CD (GitHub Actions)
|
||||||
|
|
||||||
|
Pipeline jobs run in order: unit tests → integration tests → E2E (Docker-based) → deploy (main branch only via SSH + Docker Compose).
|
||||||
@@ -8,14 +8,12 @@ COPY src/backend/src/Randall.Domain/Randall.Domain.csproj s
|
|||||||
COPY src/backend/src/Randall.Application/Randall.Application.csproj src/backend/src/Randall.Application/
|
COPY src/backend/src/Randall.Application/Randall.Application.csproj src/backend/src/Randall.Application/
|
||||||
COPY src/backend/src/Randall.Infrastructure/Randall.Infrastructure.csproj src/backend/src/Randall.Infrastructure/
|
COPY src/backend/src/Randall.Infrastructure/Randall.Infrastructure.csproj src/backend/src/Randall.Infrastructure/
|
||||||
COPY src/backend/src/Randall.Api/Randall.Api.csproj src/backend/src/Randall.Api/
|
COPY src/backend/src/Randall.Api/Randall.Api.csproj src/backend/src/Randall.Api/
|
||||||
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
RUN dotnet restore "src/backend/src/Randall.Api/Randall.Api.csproj"
|
||||||
dotnet restore "src/backend/src/Randall.Api/Randall.Api.csproj"
|
|
||||||
|
|
||||||
# Build — copy source only (no bin/obj/db files)
|
# Build — copy source only (no bin/obj/db files)
|
||||||
COPY src/backend/src/ src/backend/src/
|
COPY src/backend/src/ src/backend/src/
|
||||||
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
RUN dotnet publish "src/backend/src/Randall.Api/Randall.Api.csproj" \
|
||||||
dotnet publish "src/backend/src/Randall.Api/Randall.Api.csproj" \
|
-c Release -o /app/publish
|
||||||
-c Release --no-restore -o /app/publish
|
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM public.ecr.aws/docker/library/node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies — copy lockfiles first to cache the npm layer
|
# Install dependencies — copy lockfiles first to cache the npm layer
|
||||||
COPY src/frontend/package.json src/frontend/package-lock.json ./
|
COPY src/frontend/package.json src/frontend/package-lock.json ./
|
||||||
RUN --mount=type=cache,id=npm,target=/root/.npm \
|
RUN npm ci
|
||||||
npm ci --prefer-offline
|
|
||||||
|
|
||||||
# Copy source
|
# Copy source
|
||||||
COPY src/frontend/index.html ./
|
COPY src/frontend/index.html ./
|
||||||
@@ -18,7 +17,7 @@ COPY src/frontend/public/ ./public/
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Serve stage
|
# Serve stage
|
||||||
FROM public.ecr.aws/docker/library/nginx:alpine AS runtime
|
FROM nginx:alpine AS runtime
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY cicd/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY cicd/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
image: localhost/randall/backend:latest
|
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: cicd/docker/Dockerfile.backend
|
dockerfile: cicd/docker/Dockerfile.backend
|
||||||
@@ -21,7 +20,6 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: localhost/randall/frontend:latest
|
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: cicd/docker/Dockerfile.frontend
|
dockerfile: cicd/docker/Dockerfile.frontend
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Randall.Application.Admin.AddUser;
|
|
||||||
using Randall.Application.Admin.ApproveUser;
|
using Randall.Application.Admin.ApproveUser;
|
||||||
using Randall.Application.Admin.DeleteUser;
|
using Randall.Application.Admin.DeleteUser;
|
||||||
using Randall.Application.Admin.GetAllUsers;
|
using Randall.Application.Admin.GetAllUsers;
|
||||||
@@ -10,13 +9,10 @@ using Randall.Application.Admin.MakeAdmin;
|
|||||||
|
|
||||||
namespace Randall.Api.Admin;
|
namespace Randall.Api.Admin;
|
||||||
|
|
||||||
public record AddUserRequest(string Email, string Name, string Password);
|
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/admin")]
|
[Route("api/admin")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class AdminController(
|
public class AdminController(
|
||||||
AddUserHandler addUserHandler,
|
|
||||||
GetAllUsersHandler getAllUsersHandler,
|
GetAllUsersHandler getAllUsersHandler,
|
||||||
GetPendingUsersHandler getPendingUsersHandler,
|
GetPendingUsersHandler getPendingUsersHandler,
|
||||||
ApproveUserHandler approveUserHandler,
|
ApproveUserHandler approveUserHandler,
|
||||||
@@ -27,16 +23,6 @@ public class AdminController(
|
|||||||
private Guid RequesterId => Guid.Parse(User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier)
|
private Guid RequesterId => Guid.Parse(User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier)
|
||||||
?? User.FindFirstValue("sub")!);
|
?? User.FindFirstValue("sub")!);
|
||||||
|
|
||||||
[HttpPost("users")]
|
|
||||||
public async Task<IActionResult> AddUser([FromBody] AddUserRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (!IsAdmin) return Forbid();
|
|
||||||
var result = await addUserHandler.HandleAsync(new AddUserCommand(request.Email, request.Name, request.Password), ct);
|
|
||||||
if (!result.IsSuccess)
|
|
||||||
return BadRequest(new ProblemDetails { Detail = result.Error });
|
|
||||||
return CreatedAtAction(nameof(GetAllUsers), result.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("users")]
|
[HttpGet("users")]
|
||||||
public async Task<IActionResult> GetAllUsers(CancellationToken ct)
|
public async Task<IActionResult> GetAllUsers(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace Randall.Application.Admin.AddUser;
|
|
||||||
|
|
||||||
public record AddUserCommand(string Email, string Name, string Password);
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using Randall.Application.Admin.GetAllUsers;
|
|
||||||
using Randall.Application.Common;
|
|
||||||
using Randall.Domain.Common;
|
|
||||||
using Randall.Domain.Users;
|
|
||||||
|
|
||||||
namespace Randall.Application.Admin.AddUser;
|
|
||||||
|
|
||||||
public class AddUserHandler(IUserRepository userRepository, IPasswordHasher passwordHasher)
|
|
||||||
{
|
|
||||||
public async Task<Result<AdminUserDto>> HandleAsync(AddUserCommand command, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var exists = await userRepository.ExistsByEmailAsync(command.Email, ct);
|
|
||||||
if (exists)
|
|
||||||
return Result.Failure<AdminUserDto>("An account with this email already exists.");
|
|
||||||
|
|
||||||
var hash = passwordHasher.Hash(command.Password);
|
|
||||||
|
|
||||||
var result = User.Create(command.Email, command.Name, hash);
|
|
||||||
if (!result.IsSuccess)
|
|
||||||
return Result.Failure<AdminUserDto>(result.Error!);
|
|
||||||
|
|
||||||
var user = result.Value!;
|
|
||||||
user.Approve();
|
|
||||||
|
|
||||||
await userRepository.AddAsync(user, ct);
|
|
||||||
await userRepository.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return Result.Success(new AdminUserDto(user.Id, user.Name, user.Email, user.IsApproved, user.IsAdmin));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Randall.Application.Admin.AddUser;
|
|
||||||
using Randall.Application.Admin.ApproveUser;
|
using Randall.Application.Admin.ApproveUser;
|
||||||
using Randall.Application.Admin.DeleteUser;
|
using Randall.Application.Admin.DeleteUser;
|
||||||
using Randall.Application.Admin.GetAllUsers;
|
using Randall.Application.Admin.GetAllUsers;
|
||||||
@@ -23,7 +22,6 @@ public static class DependencyInjection
|
|||||||
{
|
{
|
||||||
services.AddScoped<RegisterHandler>();
|
services.AddScoped<RegisterHandler>();
|
||||||
services.AddScoped<LoginHandler>();
|
services.AddScoped<LoginHandler>();
|
||||||
services.AddScoped<AddUserHandler>();
|
|
||||||
services.AddScoped<GetAllUsersHandler>();
|
services.AddScoped<GetAllUsersHandler>();
|
||||||
services.AddScoped<GetPendingUsersHandler>();
|
services.AddScoped<GetPendingUsersHandler>();
|
||||||
services.AddScoped<ApproveUserHandler>();
|
services.AddScoped<ApproveUserHandler>();
|
||||||
|
|||||||
@@ -13,16 +13,11 @@ public class Reservation : Entity
|
|||||||
public ReservationStatus Status { get; private set; }
|
public ReservationStatus Status { get; private set; }
|
||||||
public DateTime CreatedAt { get; private set; }
|
public DateTime CreatedAt { get; private set; }
|
||||||
|
|
||||||
internal Reservation(
|
// EF Core constructor
|
||||||
Guid id, Guid workplaceId, string employeeEmail, string employeeName,
|
private Reservation() : base()
|
||||||
DateOnly date, ReservationStatus status, DateTime createdAt) : base(id)
|
|
||||||
{
|
{
|
||||||
WorkplaceId = workplaceId;
|
EmployeeEmail = string.Empty;
|
||||||
EmployeeEmail = employeeEmail;
|
EmployeeName = string.Empty;
|
||||||
EmployeeName = employeeName;
|
|
||||||
Date = date;
|
|
||||||
Status = status;
|
|
||||||
CreatedAt = createdAt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Reservation(Guid workplaceId, string employeeEmail, string employeeName, DateOnly date)
|
private Reservation(Guid workplaceId, string employeeEmail, string employeeName, DateOnly date)
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace Randall.Domain.Reservations;
|
|
||||||
|
|
||||||
public static class ReservationFactory
|
|
||||||
{
|
|
||||||
public static Reservation Reconstitute(
|
|
||||||
Guid id, Guid workplaceId, string employeeEmail, string employeeName,
|
|
||||||
DateOnly date, ReservationStatus status, DateTime createdAt) =>
|
|
||||||
new(id, workplaceId, employeeEmail, employeeName, date, status, createdAt);
|
|
||||||
}
|
|
||||||
@@ -10,13 +10,11 @@ public class User : Entity
|
|||||||
public bool IsApproved { get; private set; }
|
public bool IsApproved { get; private set; }
|
||||||
public bool IsAdmin { get; private set; }
|
public bool IsAdmin { get; private set; }
|
||||||
|
|
||||||
internal User(Guid id, string email, string name, string passwordHash, bool isApproved, bool isAdmin) : base(id)
|
private User() : base()
|
||||||
{
|
{
|
||||||
Email = email;
|
Email = string.Empty;
|
||||||
Name = name;
|
Name = string.Empty;
|
||||||
PasswordHash = passwordHash;
|
PasswordHash = string.Empty;
|
||||||
IsApproved = isApproved;
|
|
||||||
IsAdmin = isAdmin;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private User(string email, string name, string passwordHash, bool isAdmin) : base()
|
private User(string email, string name, string passwordHash, bool isAdmin) : base()
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Randall.Domain.Users;
|
|
||||||
|
|
||||||
public static class UserFactory
|
|
||||||
{
|
|
||||||
public static User Reconstitute(Guid id, string email, string name, string passwordHash, bool isApproved, bool isAdmin) =>
|
|
||||||
new(id, email, name, passwordHash, isApproved, isAdmin);
|
|
||||||
}
|
|
||||||
@@ -8,11 +8,10 @@ public class Workplace : Entity
|
|||||||
public string Location { get; private set; }
|
public string Location { get; private set; }
|
||||||
public bool IsActive { get; private set; }
|
public bool IsActive { get; private set; }
|
||||||
|
|
||||||
internal Workplace(Guid id, string name, string location, bool isActive) : base(id)
|
private Workplace() : base()
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = string.Empty;
|
||||||
Location = location;
|
Location = string.Empty;
|
||||||
IsActive = isActive;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Workplace(string name, string location) : base()
|
public Workplace(string name, string location) : base()
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Randall.Domain.Workplaces;
|
|
||||||
|
|
||||||
public static class WorkplaceFactory
|
|
||||||
{
|
|
||||||
public static Workplace Reconstitute(Guid id, string name, string location, bool isActive) =>
|
|
||||||
new(id, name, location, isActive);
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Randall.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Randall.Infrastructure.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20260327154255_SeparateStorageModels")]
|
|
||||||
partial class SeparateStorageModels
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
|
||||||
|
|
||||||
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.ReservationRecord", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<DateOnly>("Date")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("EmployeeEmail")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("EmployeeName")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<int>("Status")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<Guid>("WorkplaceId")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("EmployeeEmail", "Date");
|
|
||||||
|
|
||||||
b.HasIndex("WorkplaceId", "Date");
|
|
||||||
|
|
||||||
b.ToTable("Reservations", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.UserRecord", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<bool>("IsAdmin")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<bool>("IsApproved")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Email")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Users", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.WorkplaceRecord", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<string>("Location")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("Workplaces", (string)null);
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Randall.Infrastructure.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class SeparateStorageModels : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ namespace Randall.Infrastructure.Migrations
|
|||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||||
|
|
||||||
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.ReservationRecord", b =>
|
modelBuilder.Entity("Randall.Domain.Reservations.Reservation", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -51,10 +51,10 @@ namespace Randall.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("WorkplaceId", "Date");
|
b.HasIndex("WorkplaceId", "Date");
|
||||||
|
|
||||||
b.ToTable("Reservations", (string)null);
|
b.ToTable("Reservations");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.UserRecord", b =>
|
modelBuilder.Entity("Randall.Domain.Users.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -85,10 +85,10 @@ namespace Randall.Infrastructure.Migrations
|
|||||||
b.HasIndex("Email")
|
b.HasIndex("Email")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Users", (string)null);
|
b.ToTable("Users");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.WorkplaceRecord", b =>
|
modelBuilder.Entity("Randall.Domain.Workplaces.Workplace", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -109,7 +109,7 @@ namespace Randall.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("Workplaces", (string)null);
|
b.ToTable("Workplaces");
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Randall.Infrastructure.Persistence.Records;
|
using Randall.Domain.Reservations;
|
||||||
|
using Randall.Domain.Users;
|
||||||
|
using Randall.Domain.Workplaces;
|
||||||
|
|
||||||
namespace Randall.Infrastructure.Persistence;
|
namespace Randall.Infrastructure.Persistence;
|
||||||
|
|
||||||
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<WorkplaceRecord> Workplaces => Set<WorkplaceRecord>();
|
public DbSet<Workplace> Workplaces => Set<Workplace>();
|
||||||
public DbSet<ReservationRecord> Reservations => Set<ReservationRecord>();
|
public DbSet<Reservation> Reservations => Set<Reservation>();
|
||||||
public DbSet<UserRecord> Users => Set<UserRecord>();
|
public DbSet<User> Users => Set<User>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<WorkplaceRecord>(entity =>
|
modelBuilder.Entity<Workplace>(entity =>
|
||||||
{
|
{
|
||||||
entity.ToTable("Workplaces");
|
|
||||||
entity.HasKey(w => w.Id);
|
entity.HasKey(w => w.Id);
|
||||||
entity.Property(w => w.Name).IsRequired().HasMaxLength(100);
|
entity.Property(w => w.Name).IsRequired().HasMaxLength(100);
|
||||||
entity.Property(w => w.Location).IsRequired().HasMaxLength(200);
|
entity.Property(w => w.Location).IsRequired().HasMaxLength(200);
|
||||||
entity.Property(w => w.IsActive).IsRequired();
|
entity.Property(w => w.IsActive).IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<ReservationRecord>(entity =>
|
modelBuilder.Entity<Reservation>(entity =>
|
||||||
{
|
{
|
||||||
entity.ToTable("Reservations");
|
|
||||||
entity.HasKey(r => r.Id);
|
entity.HasKey(r => r.Id);
|
||||||
entity.Property(r => r.WorkplaceId).IsRequired();
|
entity.Property(r => r.WorkplaceId).IsRequired();
|
||||||
entity.Property(r => r.EmployeeEmail).IsRequired().HasMaxLength(200);
|
entity.Property(r => r.EmployeeEmail).IsRequired().HasMaxLength(200);
|
||||||
@@ -35,9 +35,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||||||
entity.HasIndex(r => new { r.EmployeeEmail, r.Date });
|
entity.HasIndex(r => new { r.EmployeeEmail, r.Date });
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<UserRecord>(entity =>
|
modelBuilder.Entity<User>(entity =>
|
||||||
{
|
{
|
||||||
entity.ToTable("Users");
|
|
||||||
entity.HasKey(u => u.Id);
|
entity.HasKey(u => u.Id);
|
||||||
entity.Property(u => u.Email).IsRequired().HasMaxLength(200);
|
entity.Property(u => u.Email).IsRequired().HasMaxLength(200);
|
||||||
entity.Property(u => u.Name).IsRequired().HasMaxLength(200);
|
entity.Property(u => u.Name).IsRequired().HasMaxLength(200);
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
using Randall.Domain.Reservations;
|
|
||||||
using Randall.Infrastructure.Persistence.Records;
|
|
||||||
|
|
||||||
namespace Randall.Infrastructure.Persistence.Mappers;
|
|
||||||
|
|
||||||
public static class ReservationMapper
|
|
||||||
{
|
|
||||||
public static Reservation ToDomain(ReservationRecord record) =>
|
|
||||||
ReservationFactory.Reconstitute(
|
|
||||||
record.Id,
|
|
||||||
record.WorkplaceId,
|
|
||||||
record.EmployeeEmail,
|
|
||||||
record.EmployeeName,
|
|
||||||
record.Date,
|
|
||||||
record.Status,
|
|
||||||
record.CreatedAt);
|
|
||||||
|
|
||||||
public static ReservationRecord ToRecord(Reservation domain) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = domain.Id,
|
|
||||||
WorkplaceId = domain.WorkplaceId,
|
|
||||||
EmployeeEmail = domain.EmployeeEmail,
|
|
||||||
EmployeeName = domain.EmployeeName,
|
|
||||||
Date = domain.Date,
|
|
||||||
Status = domain.Status,
|
|
||||||
CreatedAt = domain.CreatedAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
public static void SyncToRecord(Reservation domain, ReservationRecord record)
|
|
||||||
{
|
|
||||||
record.Status = domain.Status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using Randall.Domain.Users;
|
|
||||||
using Randall.Infrastructure.Persistence.Records;
|
|
||||||
|
|
||||||
namespace Randall.Infrastructure.Persistence.Mappers;
|
|
||||||
|
|
||||||
public static class UserMapper
|
|
||||||
{
|
|
||||||
public static User ToDomain(UserRecord record) =>
|
|
||||||
UserFactory.Reconstitute(
|
|
||||||
record.Id,
|
|
||||||
record.Email,
|
|
||||||
record.Name,
|
|
||||||
record.PasswordHash,
|
|
||||||
record.IsApproved,
|
|
||||||
record.IsAdmin);
|
|
||||||
|
|
||||||
public static UserRecord ToRecord(User domain) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = domain.Id,
|
|
||||||
Email = domain.Email,
|
|
||||||
Name = domain.Name,
|
|
||||||
PasswordHash = domain.PasswordHash,
|
|
||||||
IsApproved = domain.IsApproved,
|
|
||||||
IsAdmin = domain.IsAdmin,
|
|
||||||
};
|
|
||||||
|
|
||||||
public static void SyncToRecord(User domain, UserRecord record)
|
|
||||||
{
|
|
||||||
record.IsApproved = domain.IsApproved;
|
|
||||||
record.IsAdmin = domain.IsAdmin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using Randall.Domain.Workplaces;
|
|
||||||
using Randall.Infrastructure.Persistence.Records;
|
|
||||||
|
|
||||||
namespace Randall.Infrastructure.Persistence.Mappers;
|
|
||||||
|
|
||||||
public static class WorkplaceMapper
|
|
||||||
{
|
|
||||||
public static Workplace ToDomain(WorkplaceRecord record) =>
|
|
||||||
WorkplaceFactory.Reconstitute(
|
|
||||||
record.Id,
|
|
||||||
record.Name,
|
|
||||||
record.Location,
|
|
||||||
record.IsActive);
|
|
||||||
|
|
||||||
public static WorkplaceRecord ToRecord(Workplace domain) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = domain.Id,
|
|
||||||
Name = domain.Name,
|
|
||||||
Location = domain.Location,
|
|
||||||
IsActive = domain.IsActive,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
using Randall.Domain.Reservations;
|
|
||||||
|
|
||||||
namespace Randall.Infrastructure.Persistence.Records;
|
|
||||||
|
|
||||||
public class ReservationRecord
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public Guid WorkplaceId { get; set; }
|
|
||||||
public string EmployeeEmail { get; set; } = string.Empty;
|
|
||||||
public string EmployeeName { get; set; } = string.Empty;
|
|
||||||
public DateOnly Date { get; set; }
|
|
||||||
public ReservationStatus Status { get; set; }
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace Randall.Infrastructure.Persistence.Records;
|
|
||||||
|
|
||||||
public class UserRecord
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public string Email { get; set; } = string.Empty;
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public string PasswordHash { get; set; } = string.Empty;
|
|
||||||
public bool IsApproved { get; set; }
|
|
||||||
public bool IsAdmin { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace Randall.Infrastructure.Persistence.Records;
|
|
||||||
|
|
||||||
public class WorkplaceRecord
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public string Location { get; set; } = string.Empty;
|
|
||||||
public bool IsActive { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,40 +1,23 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Randall.Domain.Reservations;
|
using Randall.Domain.Reservations;
|
||||||
using Randall.Infrastructure.Persistence.Mappers;
|
|
||||||
using Randall.Infrastructure.Persistence.Records;
|
|
||||||
|
|
||||||
namespace Randall.Infrastructure.Persistence.Reservations;
|
namespace Randall.Infrastructure.Persistence.Reservations;
|
||||||
|
|
||||||
public class ReservationRepository(AppDbContext context) : IReservationRepository
|
public class ReservationRepository(AppDbContext context) : IReservationRepository
|
||||||
{
|
{
|
||||||
private readonly Dictionary<Guid, (Reservation Domain, ReservationRecord Record)> _tracked = [];
|
public Task<Reservation?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
|
||||||
|
context.Reservations.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||||
|
|
||||||
public async Task<Reservation?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
public async Task<IReadOnlyList<Reservation>> GetByEmployeeAsync(string employeeEmail, CancellationToken ct = default) =>
|
||||||
{
|
await context.Reservations
|
||||||
var record = await context.Reservations.FirstOrDefaultAsync(r => r.Id == id, ct);
|
.Where(r => r.EmployeeEmail.ToLower() == employeeEmail.ToLower())
|
||||||
if (record is null) return null;
|
|
||||||
var domain = ReservationMapper.ToDomain(record);
|
|
||||||
_tracked[domain.Id] = (domain, record);
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Reservation>> GetByEmployeeAsync(string employeeEmail, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
|
||||||
var records = await context.Reservations
|
|
||||||
.Where(r => r.EmployeeEmail.ToLower() == employeeEmail.ToLower() && r.Date >= today)
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return records.Select(ReservationMapper.ToDomain).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Reservation>> GetByWorkplaceAndDateAsync(
|
public async Task<IReadOnlyList<Reservation>> GetByWorkplaceAndDateAsync(
|
||||||
Guid workplaceId, DateOnly date, CancellationToken ct = default)
|
Guid workplaceId, DateOnly date, CancellationToken ct = default) =>
|
||||||
{
|
await context.Reservations
|
||||||
var records = await context.Reservations
|
|
||||||
.Where(r => r.WorkplaceId == workplaceId && r.Date == date)
|
.Where(r => r.WorkplaceId == workplaceId && r.Date == date)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return records.Select(ReservationMapper.ToDomain).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<bool> ExistsActiveForEmployeeOnDateAsync(
|
public Task<bool> ExistsActiveForEmployeeOnDateAsync(
|
||||||
string employeeEmail, DateOnly date, CancellationToken ct = default) =>
|
string employeeEmail, DateOnly date, CancellationToken ct = default) =>
|
||||||
@@ -53,24 +36,14 @@ public class ReservationRepository(AppDbContext context) : IReservationRepositor
|
|||||||
ct);
|
ct);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Reservation>> GetActiveReservationsForDateAsync(
|
public async Task<IReadOnlyList<Reservation>> GetActiveReservationsForDateAsync(
|
||||||
DateOnly date, CancellationToken ct = default)
|
DateOnly date, CancellationToken ct = default) =>
|
||||||
{
|
await context.Reservations
|
||||||
var records = await context.Reservations
|
|
||||||
.Where(r => r.Date == date && r.Status == ReservationStatus.Active)
|
.Where(r => r.Date == date && r.Status == ReservationStatus.Active)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return records.Select(ReservationMapper.ToDomain).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task AddAsync(Reservation reservation, CancellationToken ct = default)
|
public async Task AddAsync(Reservation reservation, CancellationToken ct = default) =>
|
||||||
{
|
await context.Reservations.AddAsync(reservation, ct);
|
||||||
var record = ReservationMapper.ToRecord(reservation);
|
|
||||||
await context.Reservations.AddAsync(record, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task SaveChangesAsync(CancellationToken ct = default)
|
public Task SaveChangesAsync(CancellationToken ct = default) =>
|
||||||
{
|
context.SaveChangesAsync(ct);
|
||||||
foreach (var (domain, record) in _tracked.Values)
|
|
||||||
ReservationMapper.SyncToRecord(domain, record);
|
|
||||||
return context.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Randall.Application.Common;
|
using Randall.Application.Common;
|
||||||
using Randall.Domain.Users;
|
using Randall.Domain.Users;
|
||||||
using Randall.Domain.Workplaces;
|
using Randall.Domain.Workplaces;
|
||||||
using Randall.Infrastructure.Persistence.Mappers;
|
|
||||||
|
|
||||||
namespace Randall.Infrastructure.Persistence.Seeding;
|
namespace Randall.Infrastructure.Persistence.Seeding;
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ public static class DatabaseSeeder
|
|||||||
// Seed workplaces only if none exist yet
|
// Seed workplaces only if none exist yet
|
||||||
if (!await context.Workplaces.AnyAsync())
|
if (!await context.Workplaces.AnyAsync())
|
||||||
{
|
{
|
||||||
var workplaces = ExpectedWorkplaces.Select(w => WorkplaceMapper.ToRecord(new Workplace(w.Name, w.Location)));
|
var workplaces = ExpectedWorkplaces.Select(w => new Workplace(w.Name, w.Location));
|
||||||
context.Workplaces.AddRange(workplaces);
|
context.Workplaces.AddRange(workplaces);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
@@ -36,7 +35,7 @@ public static class DatabaseSeeder
|
|||||||
{
|
{
|
||||||
var hash = passwordHasher.Hash(AdminPassword);
|
var hash = passwordHasher.Hash(AdminPassword);
|
||||||
var admin = User.Create(AdminEmail, "Admin", hash, isAdmin: true).Value!;
|
var admin = User.Create(AdminEmail, "Admin", hash, isAdmin: true).Value!;
|
||||||
context.Users.Add(UserMapper.ToRecord(admin));
|
context.Users.Add(admin);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,37 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Randall.Domain.Users;
|
using Randall.Domain.Users;
|
||||||
using Randall.Infrastructure.Persistence.Mappers;
|
|
||||||
using Randall.Infrastructure.Persistence.Records;
|
|
||||||
|
|
||||||
namespace Randall.Infrastructure.Persistence.Users;
|
namespace Randall.Infrastructure.Persistence.Users;
|
||||||
|
|
||||||
public class UserRepository(AppDbContext context) : IUserRepository
|
public class UserRepository(AppDbContext context) : IUserRepository
|
||||||
{
|
{
|
||||||
private readonly Dictionary<Guid, (User Domain, UserRecord Record)> _tracked = [];
|
public Task<User?> GetByEmailAsync(string email, CancellationToken ct = default) =>
|
||||||
|
context.Users.FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), ct);
|
||||||
|
|
||||||
private User Track(UserRecord record)
|
public Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
|
||||||
{
|
context.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||||
var domain = UserMapper.ToDomain(record);
|
|
||||||
_tracked[domain.Id] = (domain, record);
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<User?> GetByEmailAsync(string email, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var record = await context.Users.FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), ct);
|
|
||||||
return record is null ? null : Track(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var record = await context.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
|
||||||
return record is null ? null : Track(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<bool> ExistsByEmailAsync(string email, CancellationToken ct = default) =>
|
public Task<bool> ExistsByEmailAsync(string email, CancellationToken ct = default) =>
|
||||||
context.Users.AnyAsync(u => u.Email == email.ToLowerInvariant(), ct);
|
context.Users.AnyAsync(u => u.Email == email.ToLowerInvariant(), ct);
|
||||||
|
|
||||||
public async Task<List<User>> GetPendingAsync(CancellationToken ct = default)
|
public Task<List<User>> GetPendingAsync(CancellationToken ct = default) =>
|
||||||
{
|
context.Users.Where(u => !u.IsApproved && !u.IsAdmin).ToListAsync(ct);
|
||||||
var records = await context.Users.Where(u => !u.IsApproved && !u.IsAdmin).ToListAsync(ct);
|
|
||||||
return records.Select(UserMapper.ToDomain).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<User>> GetAllAsync(CancellationToken ct = default)
|
public Task<List<User>> GetAllAsync(CancellationToken ct = default) =>
|
||||||
{
|
context.Users.OrderBy(u => u.Name).ToListAsync(ct);
|
||||||
var records = await context.Users.OrderBy(u => u.Name).ToListAsync(ct);
|
|
||||||
return records.Select(UserMapper.ToDomain).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<int> CountAdminsAsync(CancellationToken ct = default) =>
|
public Task<int> CountAdminsAsync(CancellationToken ct = default) =>
|
||||||
context.Users.CountAsync(u => u.IsAdmin);
|
context.Users.CountAsync(u => u.IsAdmin);
|
||||||
|
|
||||||
public async Task<List<User>> GetAllNonAdminAsync(CancellationToken ct = default)
|
public Task<List<User>> GetAllNonAdminAsync(CancellationToken ct = default) =>
|
||||||
{
|
context.Users.Where(u => !u.IsAdmin).OrderBy(u => u.Name).ToListAsync(ct);
|
||||||
var records = await context.Users.Where(u => !u.IsAdmin).OrderBy(u => u.Name).ToListAsync(ct);
|
|
||||||
return records.Select(UserMapper.ToDomain).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task AddAsync(User user, CancellationToken ct = default)
|
public async Task AddAsync(User user, CancellationToken ct = default) =>
|
||||||
{
|
await context.Users.AddAsync(user, ct);
|
||||||
var record = UserMapper.ToRecord(user);
|
|
||||||
await context.Users.AddAsync(record, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Delete(User user)
|
public void Delete(User user) =>
|
||||||
{
|
context.Users.Remove(user);
|
||||||
if (_tracked.TryGetValue(user.Id, out var entry))
|
|
||||||
context.Users.Remove(entry.Record);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task SaveChangesAsync(CancellationToken ct = default)
|
public Task SaveChangesAsync(CancellationToken ct = default) =>
|
||||||
{
|
context.SaveChangesAsync(ct);
|
||||||
foreach (var (domain, record) in _tracked.Values)
|
|
||||||
UserMapper.SyncToRecord(domain, record);
|
|
||||||
return context.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Randall.Domain.Workplaces;
|
using Randall.Domain.Workplaces;
|
||||||
using Randall.Infrastructure.Persistence.Mappers;
|
|
||||||
|
|
||||||
namespace Randall.Infrastructure.Persistence.Workplaces;
|
namespace Randall.Infrastructure.Persistence.Workplaces;
|
||||||
|
|
||||||
public class WorkplaceRepository(AppDbContext context) : IWorkplaceRepository
|
public class WorkplaceRepository(AppDbContext context) : IWorkplaceRepository
|
||||||
{
|
{
|
||||||
public async Task<Workplace?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
public Task<Workplace?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
|
||||||
{
|
context.Workplaces.FirstOrDefaultAsync(w => w.Id == id, ct);
|
||||||
var record = await context.Workplaces.FirstOrDefaultAsync(w => w.Id == id, ct);
|
|
||||||
return record is null ? null : WorkplaceMapper.ToDomain(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Workplace>> GetAllActiveAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<Workplace>> GetAllActiveAsync(CancellationToken ct = default) =>
|
||||||
{
|
await context.Workplaces.Where(w => w.IsActive).ToListAsync(ct);
|
||||||
var records = await context.Workplaces.Where(w => w.IsActive).ToListAsync(ct);
|
|
||||||
return records.Select(WorkplaceMapper.ToDomain).ToList();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,85 +146,4 @@ public class AdminTests(CustomWebApplicationFactory factory) : IClassFixture<Cus
|
|||||||
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AddUser_WithoutAuth_Returns401()
|
|
||||||
{
|
|
||||||
var client = factory.CreateClient();
|
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/api/admin/users",
|
|
||||||
new { Email = "new@test.com", Name = "New User", Password = "Test@1234" });
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AddUser_WithNonAdmin_Returns403()
|
|
||||||
{
|
|
||||||
var email = $"{Guid.NewGuid():N}@test.com";
|
|
||||||
var token = await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email);
|
|
||||||
|
|
||||||
var client = factory.CreateClient();
|
|
||||||
AuthHelper.SetBearerToken(client, token);
|
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/api/admin/users",
|
|
||||||
new { Email = $"{Guid.NewGuid():N}@test.com", Name = "New User", Password = "Test@1234" });
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AddUser_WithAdmin_Returns201WithUserDetails()
|
|
||||||
{
|
|
||||||
var client = factory.CreateClient();
|
|
||||||
var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
|
|
||||||
AuthHelper.SetBearerToken(client, adminLogin.Token);
|
|
||||||
|
|
||||||
var email = $"{Guid.NewGuid():N}@test.com";
|
|
||||||
var response = await client.PostAsJsonAsync("/api/admin/users",
|
|
||||||
new { Email = email, Name = "New User", Password = "Test@1234" });
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
|
||||||
var user = await response.Content.ReadFromJsonAsync<AdminUserResponse>(AuthHelper.JsonOptions);
|
|
||||||
Assert.NotNull(user);
|
|
||||||
Assert.Equal(email, user.Email);
|
|
||||||
Assert.Equal("New User", user.Name);
|
|
||||||
Assert.True(user.IsApproved);
|
|
||||||
Assert.False(user.IsAdmin);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AddUser_DuplicateEmail_ReturnsBadRequest()
|
|
||||||
{
|
|
||||||
var client = factory.CreateClient();
|
|
||||||
var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
|
|
||||||
AuthHelper.SetBearerToken(client, adminLogin.Token);
|
|
||||||
|
|
||||||
var email = $"{Guid.NewGuid():N}@test.com";
|
|
||||||
await client.PostAsJsonAsync("/api/admin/users",
|
|
||||||
new { Email = email, Name = "First User", Password = "Test@1234" });
|
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/api/admin/users",
|
|
||||||
new { Email = email, Name = "Duplicate User", Password = "Test@1234" });
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AddUser_CreatedUserCanLoginImmediately()
|
|
||||||
{
|
|
||||||
var adminClient = factory.CreateClient();
|
|
||||||
var adminLogin = await AuthHelper.LoginAsAdminAsync(adminClient);
|
|
||||||
AuthHelper.SetBearerToken(adminClient, adminLogin.Token);
|
|
||||||
|
|
||||||
var email = $"{Guid.NewGuid():N}@test.com";
|
|
||||||
const string password = "Test@1234";
|
|
||||||
await adminClient.PostAsJsonAsync("/api/admin/users",
|
|
||||||
new { Email = email, Name = "Direct User", Password = password });
|
|
||||||
|
|
||||||
var loginClient = factory.CreateClient();
|
|
||||||
var login = await AuthHelper.LoginAsync(loginClient, email, password);
|
|
||||||
|
|
||||||
Assert.NotEmpty(login.Token);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace Randall.Api.IntegrationTests;
|
namespace Randall.Api.IntegrationTests;
|
||||||
@@ -29,9 +28,6 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
{
|
{
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
if (disposing && File.Exists(_dbPath))
|
if (disposing && File.Exists(_dbPath))
|
||||||
{
|
|
||||||
SqliteConnection.ClearAllPools();
|
|
||||||
File.Delete(_dbPath);
|
File.Delete(_dbPath);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
using Randall.Domain.Reservations;
|
|
||||||
|
|
||||||
namespace Randall.Domain.UnitTests.Reservations;
|
|
||||||
|
|
||||||
public class ReservationFactoryTests
|
|
||||||
{
|
|
||||||
private static readonly Guid Id = Guid.NewGuid();
|
|
||||||
private static readonly Guid WorkplaceId = Guid.NewGuid();
|
|
||||||
private const string Email = "jane@company.com";
|
|
||||||
private const string Name = "Jane Smith";
|
|
||||||
private static readonly DateOnly Date = new(2026, 6, 1);
|
|
||||||
private static readonly DateTime CreatedAt = new(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_MapsAllFieldsExactly()
|
|
||||||
{
|
|
||||||
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, Date, ReservationStatus.Active, CreatedAt);
|
|
||||||
|
|
||||||
Assert.Equal(Id, reservation.Id);
|
|
||||||
Assert.Equal(WorkplaceId, reservation.WorkplaceId);
|
|
||||||
Assert.Equal(Email, reservation.EmployeeEmail);
|
|
||||||
Assert.Equal(Name, reservation.EmployeeName);
|
|
||||||
Assert.Equal(Date, reservation.Date);
|
|
||||||
Assert.Equal(ReservationStatus.Active, reservation.Status);
|
|
||||||
Assert.Equal(CreatedAt, reservation.CreatedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_PreservesProvidedId()
|
|
||||||
{
|
|
||||||
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, Date, ReservationStatus.Active, CreatedAt);
|
|
||||||
|
|
||||||
Assert.Equal(Id, reservation.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_CanRestoreActiveReservation()
|
|
||||||
{
|
|
||||||
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, Date, ReservationStatus.Active, CreatedAt);
|
|
||||||
|
|
||||||
Assert.Equal(ReservationStatus.Active, reservation.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_CanRestoreCancelledReservation()
|
|
||||||
{
|
|
||||||
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, Date, ReservationStatus.Cancelled, CreatedAt);
|
|
||||||
|
|
||||||
Assert.Equal(ReservationStatus.Cancelled, reservation.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_CanRestorePastReservation()
|
|
||||||
{
|
|
||||||
var pastDate = new DateOnly(2020, 1, 1);
|
|
||||||
|
|
||||||
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, pastDate, ReservationStatus.Active, CreatedAt);
|
|
||||||
|
|
||||||
Assert.Equal(pastDate, reservation.Date);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
using Randall.Domain.Users;
|
|
||||||
|
|
||||||
namespace Randall.Domain.UnitTests.Users;
|
|
||||||
|
|
||||||
public class UserFactoryTests
|
|
||||||
{
|
|
||||||
private static readonly Guid Id = Guid.NewGuid();
|
|
||||||
private const string Email = "jane@company.com";
|
|
||||||
private const string Name = "Jane Smith";
|
|
||||||
private const string PasswordHash = "hashed-password";
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_MapsAllFieldsExactly()
|
|
||||||
{
|
|
||||||
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: true, isAdmin: false);
|
|
||||||
|
|
||||||
Assert.Equal(Id, user.Id);
|
|
||||||
Assert.Equal(Email, user.Email);
|
|
||||||
Assert.Equal(Name, user.Name);
|
|
||||||
Assert.Equal(PasswordHash, user.PasswordHash);
|
|
||||||
Assert.True(user.IsApproved);
|
|
||||||
Assert.False(user.IsAdmin);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_PreservesProvidedId()
|
|
||||||
{
|
|
||||||
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: false, isAdmin: false);
|
|
||||||
|
|
||||||
Assert.Equal(Id, user.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_CanRestoreApprovedNonAdminUser()
|
|
||||||
{
|
|
||||||
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: true, isAdmin: false);
|
|
||||||
|
|
||||||
Assert.True(user.IsApproved);
|
|
||||||
Assert.False(user.IsAdmin);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_CanRestoreUnapprovedUser()
|
|
||||||
{
|
|
||||||
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: false, isAdmin: false);
|
|
||||||
|
|
||||||
Assert.False(user.IsApproved);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_CanRestoreAdminUser()
|
|
||||||
{
|
|
||||||
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: true, isAdmin: true);
|
|
||||||
|
|
||||||
Assert.True(user.IsAdmin);
|
|
||||||
Assert.True(user.IsApproved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
using Randall.Domain.Workplaces;
|
|
||||||
|
|
||||||
namespace Randall.Domain.UnitTests.Workplaces;
|
|
||||||
|
|
||||||
public class WorkplaceFactoryTests
|
|
||||||
{
|
|
||||||
private static readonly Guid Id = Guid.NewGuid();
|
|
||||||
private const string Name = "D13";
|
|
||||||
private const string Location = "Pod A";
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_MapsAllFieldsExactly()
|
|
||||||
{
|
|
||||||
var workplace = WorkplaceFactory.Reconstitute(Id, Name, Location, isActive: true);
|
|
||||||
|
|
||||||
Assert.Equal(Id, workplace.Id);
|
|
||||||
Assert.Equal(Name, workplace.Name);
|
|
||||||
Assert.Equal(Location, workplace.Location);
|
|
||||||
Assert.True(workplace.IsActive);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_PreservesProvidedId()
|
|
||||||
{
|
|
||||||
var workplace = WorkplaceFactory.Reconstitute(Id, Name, Location, isActive: true);
|
|
||||||
|
|
||||||
Assert.Equal(Id, workplace.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_CanRestoreActiveWorkplace()
|
|
||||||
{
|
|
||||||
var workplace = WorkplaceFactory.Reconstitute(Id, Name, Location, isActive: true);
|
|
||||||
|
|
||||||
Assert.True(workplace.IsActive);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reconstitute_CanRestoreInactiveWorkplace()
|
|
||||||
{
|
|
||||||
var workplace = WorkplaceFactory.Reconstitute(Id, Name, Location, isActive: false);
|
|
||||||
|
|
||||||
Assert.False(workplace.IsActive);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>randall</title>
|
<title>frontend</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Rubik+Mono+One&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<PlannerPage auth={auth} onLogout={handleLogout} />} />
|
<Route path="/" element={<PlannerPage auth={auth} onLogout={handleLogout} />} />
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={auth.isAdmin ? <AdminPage onLogout={handleLogout} /> : <Navigate to="/" replace />}
|
element={auth.isAdmin ? <AdminPage /> : <Navigate to="/" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AddUserRequest, AdminUser, AuthResponse, CreateReservationRequest, PendingUser, RegisterPendingResponse, Reservation, WorkplaceScheduleItem } from './types';
|
import type { AdminUser, AuthResponse, CreateReservationRequest, PendingUser, RegisterPendingResponse, Reservation, WorkplaceScheduleItem } from './types';
|
||||||
|
|
||||||
const BASE = '/api';
|
const BASE = '/api';
|
||||||
|
|
||||||
@@ -38,15 +38,6 @@ export const api = {
|
|||||||
return handleResponse<RegisterPendingResponse>(res);
|
return handleResponse<RegisterPendingResponse>(res);
|
||||||
},
|
},
|
||||||
|
|
||||||
async addUser(data: AddUserRequest): Promise<AdminUser> {
|
|
||||||
const res = await fetch(`${BASE}/admin/users`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
return handleResponse<AdminUser>(res);
|
|
||||||
},
|
|
||||||
|
|
||||||
getAdminUsers(): Promise<AdminUser[]> {
|
getAdminUsers(): Promise<AdminUser[]> {
|
||||||
return fetch(`${BASE}/admin/users`, {
|
return fetch(`${BASE}/admin/users`, {
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
|
|||||||
@@ -59,9 +59,3 @@ export interface AdminUser {
|
|||||||
isApproved: boolean;
|
isApproved: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddUserRequest {
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ interface CancelModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PURPLE = '#5b4fc7';
|
|
||||||
const PURPLE_DEEP = '#3f33a8';
|
|
||||||
const SAGE = '#c7d4b8';
|
|
||||||
const SAGE_DEEP = '#a9bb96';
|
|
||||||
const PAPER = '#f4f3ee';
|
|
||||||
|
|
||||||
export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalProps) {
|
export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -30,77 +24,30 @@ export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalP
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
style={{
|
|
||||||
position: 'fixed', inset: 0,
|
|
||||||
background: 'rgba(42,31,107,0.35)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
zIndex: 50,
|
|
||||||
}}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md mx-4"
|
||||||
aria-modal="true"
|
|
||||||
style={{
|
|
||||||
background: PAPER, borderRadius: 18, padding: 32,
|
|
||||||
width: '100%', maxWidth: 380, margin: '0 16px',
|
|
||||||
border: '1px solid rgba(91,79,199,0.12)',
|
|
||||||
boxShadow: '0 20px 60px -12px rgba(42,31,107,0.25)',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div style={{
|
<h2 className="text-xl font-semibold text-slate-800 mb-1">Cancel reservation</h2>
|
||||||
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
|
<p className="text-sm text-slate-500 mb-6">
|
||||||
color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500,
|
Cancel your booking for <span className="font-medium text-slate-700">{deskName}</span> on{' '}
|
||||||
}}>
|
<span className="font-medium text-slate-700">{date}</span>?
|
||||||
Cancel reservation
|
</p>
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontFamily: "'Rubik Mono One', monospace",
|
|
||||||
fontSize: 36, letterSpacing: '-0.03em', color: PURPLE,
|
|
||||||
lineHeight: 0.95, marginBottom: 6,
|
|
||||||
}}>
|
|
||||||
{deskName}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, color: PURPLE, opacity: 0.65, marginBottom: 24 }}>
|
|
||||||
{date}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg mb-4">{error}</p>}
|
||||||
<div style={{
|
|
||||||
fontSize: 13, color: '#c0392b',
|
|
||||||
background: 'rgba(192,57,43,0.08)',
|
|
||||||
borderRadius: 8, padding: '10px 14px',
|
|
||||||
marginBottom: 18,
|
|
||||||
}}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors"
|
||||||
flex: 1, padding: '11px 16px', borderRadius: 99,
|
|
||||||
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
|
|
||||||
color: PURPLE_DEEP, fontSize: 13, fontWeight: 500,
|
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Keep it
|
Keep it
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{
|
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-500 rounded-lg hover:bg-red-600 disabled:opacity-50 transition-colors"
|
||||||
flex: 1, padding: '11px 16px', borderRadius: 99,
|
|
||||||
background: PURPLE_DEEP, border: `1.5px solid rgba(42,31,107,0.8)`,
|
|
||||||
color: '#fff', fontSize: 13, fontWeight: 500,
|
|
||||||
cursor: loading ? 'default' : 'pointer',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
opacity: loading ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{loading ? 'Cancelling…' : 'Cancel reservation'}
|
{loading ? 'Cancelling…' : 'Cancel reservation'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,104 +1,72 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
interface DeskProps {
|
interface DeskProps {
|
||||||
name: string;
|
name: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
reserved: boolean;
|
reserved: boolean; // reserved by the current user
|
||||||
reservedBy?: string;
|
reservedBy?: string; // name of whoever reserved it (when taken by someone else)
|
||||||
rotate?: 'cw' | 'ccw';
|
rotate?: 'cw' | 'ccw';
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PURPLE = '#5b4fc7';
|
|
||||||
const PURPLE_DEEP = '#3f33a8';
|
|
||||||
const SAGE = '#c7d4b8';
|
|
||||||
const SAGE_DEEP = '#a9bb96';
|
|
||||||
const PAPER = '#f4f3ee';
|
|
||||||
|
|
||||||
export function Desk({ name, available, reserved, reservedBy, rotate, onClick }: DeskProps) {
|
export function Desk({ name, available, reserved, reservedBy, rotate, onClick }: DeskProps) {
|
||||||
const [hover, setHover] = useState(false);
|
let bgColor: string;
|
||||||
|
let borderColor: string;
|
||||||
|
let textColor: string;
|
||||||
|
let deskColor: string;
|
||||||
|
let cursor: string;
|
||||||
|
let title: string;
|
||||||
|
|
||||||
const isMine = reserved;
|
if (reserved) {
|
||||||
const isFree = !reserved && available;
|
bgColor = 'bg-blue-50';
|
||||||
const isTaken = !reserved && !available;
|
borderColor = 'border-blue-400';
|
||||||
|
textColor = 'text-blue-700';
|
||||||
|
deskColor = '#93c5fd';
|
||||||
|
cursor = 'cursor-pointer hover:bg-blue-100';
|
||||||
|
title = 'Your reservation — click to cancel';
|
||||||
|
} else if (available) {
|
||||||
|
bgColor = 'bg-emerald-50';
|
||||||
|
borderColor = 'border-emerald-400';
|
||||||
|
textColor = 'text-emerald-700';
|
||||||
|
deskColor = '#6ee7b7';
|
||||||
|
cursor = 'cursor-pointer hover:bg-emerald-100 hover:scale-105';
|
||||||
|
title = `Reserve ${name}`;
|
||||||
|
} else {
|
||||||
|
bgColor = 'bg-slate-50';
|
||||||
|
borderColor = 'border-slate-300';
|
||||||
|
textColor = 'text-slate-500';
|
||||||
|
deskColor = '#cbd5e1';
|
||||||
|
cursor = 'cursor-default';
|
||||||
|
title = reservedBy ? `Reserved by ${reservedBy}` : `${name} is taken`;
|
||||||
|
}
|
||||||
|
|
||||||
const svgFill = isMine ? PURPLE_DEEP : isFree ? PURPLE : 'rgba(91,79,199,0.25)';
|
// Truncate long names to fit the tile
|
||||||
|
const displayName = reservedBy && reservedBy.length > 7
|
||||||
const displayLabel = isFree
|
? reservedBy.slice(0, 6) + '…'
|
||||||
? 'free'
|
: reservedBy;
|
||||||
: isMine
|
|
||||||
? 'yours'
|
|
||||||
: reservedBy
|
|
||||||
? reservedBy.length > 7
|
|
||||||
? reservedBy.slice(0, 6) + '…'
|
|
||||||
: reservedBy
|
|
||||||
: 'taken';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onMouseEnter={() => setHover(true)}
|
className={`flex flex-col items-center justify-center w-20 rounded-xl border-2 font-semibold transition-all select-none py-2 px-1 gap-0.5 ${bgColor} ${borderColor} ${textColor} ${cursor}`}
|
||||||
onMouseLeave={() => setHover(false)}
|
onClick={available || reserved ? onClick : undefined}
|
||||||
onClick={isTaken ? undefined : onClick}
|
title={title}
|
||||||
title={
|
|
||||||
isMine
|
|
||||||
? `${name} — your reservation (click to cancel)`
|
|
||||||
: isFree
|
|
||||||
? `Reserve ${name}`
|
|
||||||
: reservedBy
|
|
||||||
? `Reserved by ${reservedBy}`
|
|
||||||
: `${name} is taken`
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: 74,
|
|
||||||
height: 80,
|
|
||||||
padding: '4px',
|
|
||||||
gap: 1,
|
|
||||||
borderRadius: 12,
|
|
||||||
background: isMine ? SAGE : isFree ? PAPER : 'transparent',
|
|
||||||
border: isMine
|
|
||||||
? `1.5px solid ${SAGE_DEEP}`
|
|
||||||
: isFree
|
|
||||||
? '1px solid rgba(91,79,199,0.18)'
|
|
||||||
: '1px dashed rgba(91,79,199,0.18)',
|
|
||||||
color: isMine ? PURPLE_DEEP : isFree ? PURPLE : 'rgba(91,79,199,0.45)',
|
|
||||||
cursor: isTaken ? 'default' : 'pointer',
|
|
||||||
boxShadow: isMine
|
|
||||||
? '0 6px 16px -8px rgba(91,79,199,0.25)'
|
|
||||||
: hover && isFree
|
|
||||||
? '0 8px 20px -10px rgba(91,79,199,0.20)'
|
|
||||||
: 'none',
|
|
||||||
transform: hover && !isTaken ? 'translateY(-2px)' : 'translateY(0)',
|
|
||||||
transition: 'transform 220ms cubic-bezier(0.34,1.56,0.64,1), box-shadow 200ms, border-color 160ms',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
outline: 'none',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<svg
|
{/* Top-down desk: rectangular surface + screen stripe */}
|
||||||
viewBox="0 0 48 48"
|
<svg viewBox="0 0 48 48" className={`w-10 h-10 ${rotate === 'cw' ? 'rotate-90' : rotate === 'ccw' ? '-rotate-90' : ''}`} fill="none" aria-hidden="true">
|
||||||
width={28}
|
{/* Desk surface (wide rectangle, ~1.6:1 ratio) */}
|
||||||
height={28}
|
<rect x="2" y="10" width="44" height="28" rx="2.5" fill={deskColor} />
|
||||||
fill="none"
|
{/* Screen stripe — centered, ~30% of desk width, small margin from back edge */}
|
||||||
aria-hidden="true"
|
<rect x="17" y="13" width="14" height="5" rx="1.5" fill="#1e293b" />
|
||||||
style={{
|
{/* Chair — small square in front of desk */}
|
||||||
transform: rotate === 'cw' ? 'rotate(90deg)' : 'rotate(-90deg)',
|
<rect x="18" y="41" width="12" height="8" rx="2" fill={deskColor} />
|
||||||
transition: 'transform 200ms',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<rect x="2" y="10" width="44" height="28" rx="2.5" fill={svgFill} />
|
|
||||||
<rect x="17" y="13" width="14" height="5" rx="1.5" fill={PURPLE_DEEP} opacity="0.7" />
|
|
||||||
<rect x="18" y="41" width="12" height="8" rx="2" fill={svgFill} />
|
|
||||||
</svg>
|
</svg>
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: '-0.01em', lineHeight: 1 }}>
|
|
||||||
{name}
|
<span className="text-xs font-semibold leading-none">{name}</span>
|
||||||
</span>
|
{reserved && <span className="text-[10px] opacity-75 leading-none">Mine</span>}
|
||||||
<span style={{ fontSize: 9, opacity: 0.7, fontWeight: 500, lineHeight: 1.1, textAlign: 'center' }}>
|
{available && <span className="text-[10px] opacity-75 leading-none">Free</span>}
|
||||||
{displayLabel}
|
{!reserved && !available && (
|
||||||
</span>
|
<span className="text-[10px] opacity-75 leading-tight text-center px-0.5">
|
||||||
|
{displayName ?? 'Taken'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,64 +9,48 @@ interface ScheduleItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DeskPodProps {
|
interface DeskPodProps {
|
||||||
label: string;
|
|
||||||
desks: ScheduleItem[];
|
desks: ScheduleItem[];
|
||||||
myReservedIds: Set<string>;
|
myReservedIds: Set<string>;
|
||||||
onDeskClick: (desk: ScheduleItem) => void;
|
onDeskClick: (desk: ScheduleItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeskPod({ label, desks, myReservedIds, onDeskClick }: DeskPodProps) {
|
export function DeskPod({ desks, myReservedIds, onDeskClick }: DeskPodProps) {
|
||||||
const left = [...desks.slice(0, 4)].reverse();
|
const left = [...desks.slice(0, 4)].reverse();
|
||||||
const right = [...desks.slice(4, 8)].reverse();
|
const right = [...desks.slice(4, 8)].reverse();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, flex: '0 0 auto' }}>
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div style={{
|
<div className="bg-white border-2 border-slate-200 rounded-2xl p-5 shadow-sm">
|
||||||
fontSize: 10,
|
<div className="flex gap-6">
|
||||||
letterSpacing: '0.18em',
|
<div className="flex flex-col gap-3">
|
||||||
textTransform: 'uppercase',
|
{left.map((desk) => (
|
||||||
color: 'var(--purple)',
|
<Desk
|
||||||
opacity: 0.6,
|
key={desk.id}
|
||||||
fontWeight: 500,
|
name={desk.name}
|
||||||
}}>
|
available={desk.isAvailable}
|
||||||
{label}
|
reserved={myReservedIds.has(desk.id)}
|
||||||
</div>
|
reservedBy={desk.reservedBy ?? undefined}
|
||||||
<div style={{
|
rotate="cw"
|
||||||
display: 'inline-flex',
|
onClick={() => onDeskClick(desk)}
|
||||||
gap: 10,
|
/>
|
||||||
padding: '10px',
|
))}
|
||||||
background: 'rgba(255,255,255,0.4)',
|
</div>
|
||||||
borderRadius: 14,
|
|
||||||
border: '1px solid rgba(91,79,199,0.08)',
|
<div className="w-px bg-slate-100" />
|
||||||
width: 'fit-content',
|
|
||||||
alignSelf: 'flex-start',
|
<div className="flex flex-col gap-3">
|
||||||
}}>
|
{right.map((desk) => (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<Desk
|
||||||
{left.map((desk) => (
|
key={desk.id}
|
||||||
<Desk
|
name={desk.name}
|
||||||
key={desk.id}
|
available={desk.isAvailable}
|
||||||
name={desk.name}
|
reserved={myReservedIds.has(desk.id)}
|
||||||
available={desk.isAvailable}
|
reservedBy={desk.reservedBy ?? undefined}
|
||||||
reserved={myReservedIds.has(desk.id)}
|
rotate="ccw"
|
||||||
reservedBy={desk.reservedBy ?? undefined}
|
onClick={() => onDeskClick(desk)}
|
||||||
rotate="cw"
|
/>
|
||||||
onClick={() => onDeskClick(desk)}
|
))}
|
||||||
/>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<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}
|
|
||||||
name={desk.name}
|
|
||||||
available={desk.isAvailable}
|
|
||||||
reserved={myReservedIds.has(desk.id)}
|
|
||||||
reservedBy={desk.reservedBy ?? undefined}
|
|
||||||
rotate="ccw"
|
|
||||||
onClick={() => onDeskClick(desk)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ interface MyReservationsProps {
|
|||||||
onCancel: (reservation: Reservation) => void;
|
onCancel: (reservation: Reservation) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PURPLE = '#5b4fc7';
|
|
||||||
const PURPLE_DEEP = '#3f33a8';
|
|
||||||
const SAGE = '#c7d4b8';
|
|
||||||
const SAGE_DEEP = '#a9bb96';
|
|
||||||
|
|
||||||
export function MyReservations({ reservations, onCancel }: MyReservationsProps) {
|
export function MyReservations({ reservations, onCancel }: MyReservationsProps) {
|
||||||
const upcoming = reservations
|
const upcoming = reservations
|
||||||
.filter((r) => r.status === 'Active')
|
.filter((r) => r.status === 'Active')
|
||||||
@@ -17,46 +12,24 @@ export function MyReservations({ reservations, onCancel }: MyReservationsProps)
|
|||||||
|
|
||||||
if (upcoming.length === 0) {
|
if (upcoming.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p style={{ fontSize: 13, color: PURPLE, opacity: 0.4, textAlign: 'center', padding: '8px 0', margin: 0 }}>
|
<p className="text-sm text-slate-400 text-center py-4">No upcoming reservations.</p>
|
||||||
No upcoming reservations.
|
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<ul className="flex flex-col gap-2">
|
||||||
{upcoming.map((r) => (
|
{upcoming.map((r) => (
|
||||||
<li
|
<li
|
||||||
key={r.id}
|
key={r.id}
|
||||||
style={{
|
className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-4 py-3"
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '10px 0',
|
|
||||||
borderBottom: '1px solid rgba(91,79,199,0.08)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: PURPLE, lineHeight: 1 }}>
|
<span className="font-medium text-slate-800">{r.workplaceName}</span>
|
||||||
{r.workplaceName}
|
<div className="text-xs text-slate-400 mt-0.5">{r.date}</div>
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 11, color: PURPLE, opacity: 0.55, marginTop: 3 }}>
|
|
||||||
{r.date}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onCancel(r)}
|
onClick={() => onCancel(r)}
|
||||||
style={{
|
className="text-xs text-red-500 hover:text-red-700 font-medium px-3 py-1 rounded-lg hover:bg-red-50 transition-colors"
|
||||||
padding: '5px 12px',
|
|
||||||
borderRadius: 99,
|
|
||||||
background: SAGE,
|
|
||||||
border: `1px solid ${SAGE_DEEP}`,
|
|
||||||
color: PURPLE_DEEP,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ interface ReservationModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PURPLE = '#5b4fc7';
|
|
||||||
const PURPLE_DEEP = '#3f33a8';
|
|
||||||
const SAGE = '#c7d4b8';
|
|
||||||
const SAGE_DEEP = '#a9bb96';
|
|
||||||
const PAPER = '#f4f3ee';
|
|
||||||
|
|
||||||
export function ReservationModal({ deskName, date, onConfirm, onClose }: ReservationModalProps) {
|
export function ReservationModal({ deskName, date, onConfirm, onClose }: ReservationModalProps) {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -30,77 +24,29 @@ export function ReservationModal({ deskName, date, onConfirm, onClose }: Reserva
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
style={{
|
|
||||||
position: 'fixed', inset: 0,
|
|
||||||
background: 'rgba(42,31,107,0.35)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
zIndex: 50,
|
|
||||||
}}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-sm mx-4"
|
||||||
aria-modal="true"
|
|
||||||
style={{
|
|
||||||
background: PAPER, borderRadius: 18, padding: 32,
|
|
||||||
width: '100%', maxWidth: 360, margin: '0 16px',
|
|
||||||
border: '1px solid rgba(91,79,199,0.12)',
|
|
||||||
boxShadow: '0 20px 60px -12px rgba(42,31,107,0.25)',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div style={{
|
<h2 className="text-xl font-semibold text-slate-800 mb-1">Reserve desk</h2>
|
||||||
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
|
<p className="text-sm text-slate-500 mb-6">
|
||||||
color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500,
|
<span className="font-medium text-slate-700">{deskName}</span> — {date}
|
||||||
}}>
|
</p>
|
||||||
Reserve desk
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontFamily: "'Rubik Mono One', monospace",
|
|
||||||
fontSize: 36, letterSpacing: '-0.03em', color: PURPLE,
|
|
||||||
lineHeight: 0.95, marginBottom: 6,
|
|
||||||
}}>
|
|
||||||
{deskName}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, color: PURPLE, opacity: 0.65, marginBottom: 24 }}>
|
|
||||||
{date}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg mb-4">{error}</p>}
|
||||||
<div style={{
|
|
||||||
fontSize: 13, color: '#c0392b',
|
|
||||||
background: 'rgba(192,57,43,0.08)',
|
|
||||||
borderRadius: 8, padding: '10px 14px',
|
|
||||||
marginBottom: 18,
|
|
||||||
}}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors"
|
||||||
flex: 1, padding: '11px 16px', borderRadius: 99,
|
|
||||||
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
|
|
||||||
color: PURPLE_DEEP, fontSize: 13, fontWeight: 500,
|
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{
|
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors"
|
||||||
flex: 1, padding: '11px 16px', borderRadius: 99,
|
|
||||||
background: PURPLE, border: `1.5px solid ${PURPLE_DEEP}`,
|
|
||||||
color: '#fff', fontSize: 13, fontWeight: 500,
|
|
||||||
cursor: loading ? 'default' : 'pointer',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
opacity: loading ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{loading ? 'Reserving…' : 'Confirm'}
|
{loading ? 'Reserving…' : 'Confirm'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,27 +1,12 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #e6e7e0;
|
|
||||||
--paper: #f4f3ee;
|
|
||||||
--ink: #2a1f6b;
|
|
||||||
--purple: #5b4fc7;
|
|
||||||
--purple-deep: #3f33a8;
|
|
||||||
--sage: #c7d4b8;
|
|
||||||
--sage-deep: #a9bb96;
|
|
||||||
}
|
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
background-color: var(--bg);
|
background-color: #f8fafc;
|
||||||
color: var(--ink);
|
color: #0f172a;
|
||||||
}
|
|
||||||
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 2px solid var(--purple);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,35 +3,16 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { AdminUser } from '../api/types';
|
import type { AdminUser } from '../api/types';
|
||||||
|
|
||||||
const PURPLE = '#5b4fc7';
|
export function AdminPage() {
|
||||||
const PURPLE_DEEP = '#3f33a8';
|
|
||||||
const SAGE = '#c7d4b8';
|
|
||||||
const SAGE_DEEP = '#a9bb96';
|
|
||||||
const PAPER = '#f4f3ee';
|
|
||||||
|
|
||||||
interface AdminPageProps {
|
|
||||||
onLogout: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function monogram(name: string): string {
|
|
||||||
return name.split(' ').map((n) => n[0]).join('').slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminPage({ onLogout }: AdminPageProps) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [approvingId, setApprovingId] = useState<string | null>(null);
|
const [approvingId, setApprovingId] = useState<string | null>(null);
|
||||||
const [makingAdminId, setMakingAdminId] = useState<string | null>(null);
|
const [makingAdminId, setMakingAdminId] = useState<string | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const [confirmAdminId, setConfirmAdminId] = useState<string | null>(null);
|
const [confirmAdminId, setConfirmAdminId] = useState<string | null>(null);
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
|
||||||
const [addForm, setAddForm] = useState({ name: '', email: '', password: '' });
|
|
||||||
const [addError, setAddError] = useState('');
|
|
||||||
const [addSubmitting, setAddSubmitting] = useState(false);
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -63,7 +44,7 @@ export function AdminPage({ onLogout }: AdminPageProps) {
|
|||||||
setMakingAdminId(id);
|
setMakingAdminId(id);
|
||||||
try {
|
try {
|
||||||
await api.makeAdmin(id);
|
await api.makeAdmin(id);
|
||||||
setUsers((prev) => prev.map((u) => u.id === id ? { ...u, isAdmin: true } : u));
|
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||||||
setConfirmAdminId(null);
|
setConfirmAdminId(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to make user admin');
|
setError(err instanceof Error ? err.message : 'Failed to make user admin');
|
||||||
@@ -85,472 +66,147 @@ export function AdminPage({ onLogout }: AdminPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddUser(e: React.FormEvent) {
|
const pending = users.filter((u) => !u.isApproved);
|
||||||
e.preventDefault();
|
const approved = users.filter((u) => u.isApproved);
|
||||||
setAddSubmitting(true);
|
|
||||||
setAddError('');
|
|
||||||
try {
|
|
||||||
const newUser = await api.addUser(addForm);
|
|
||||||
setUsers((prev) => [...prev, newUser]);
|
|
||||||
setShowAddModal(false);
|
|
||||||
setAddForm({ name: '', email: '', password: '' });
|
|
||||||
} catch (err) {
|
|
||||||
setAddError(err instanceof Error ? err.message : 'Failed to add user');
|
|
||||||
} finally {
|
|
||||||
setAddSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = users.filter((u) => {
|
function UserRow({ user }: { user: AdminUser }) {
|
||||||
const q = search.toLowerCase();
|
const busy = approvingId === user.id || makingAdminId === user.id || deletingId === user.id;
|
||||||
return !q || u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q);
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalUsers = users.length;
|
return (
|
||||||
const approvedCount = users.filter((u) => u.isApproved).length;
|
<li className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-5 py-4 shadow-sm">
|
||||||
const adminCount = users.filter((u) => u.isAdmin).length;
|
<div>
|
||||||
const pendingCount = users.filter((u) => !u.isApproved).length;
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-slate-800">{user.name}</p>
|
||||||
const kpis = [
|
{user.isAdmin && (
|
||||||
{ label: 'Total users', value: String(totalUsers).padStart(2, '0') },
|
<span className="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 text-amber-700">
|
||||||
{ label: 'Approved', value: String(approvedCount).padStart(2, '0') },
|
Admin
|
||||||
{ label: 'Admins', value: String(adminCount).padStart(2, '0') },
|
</span>
|
||||||
{ label: 'Pending', value: String(pendingCount).padStart(2, '0') },
|
)}
|
||||||
];
|
|
||||||
|
|
||||||
const TABLE_COLS = '2fr 2fr 1fr 1fr auto';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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('/')}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 1,
|
|
||||||
color: PURPLE,
|
|
||||||
fontFamily: "'Rubik Mono One', monospace",
|
|
||||||
fontSize: 18, letterSpacing: '-0.02em', cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ opacity: 0.6 }}>{'{'}</span>
|
|
||||||
<span style={{ padding: '0 4px' }}>randall</span>
|
|
||||||
<span style={{ opacity: 0.6 }}>{'}'}</span>
|
|
||||||
</div>
|
|
||||||
<span style={{
|
|
||||||
fontSize: 11, letterSpacing: '0.16em', textTransform: 'uppercase',
|
|
||||||
color: PURPLE, padding: '3px 9px', borderRadius: 99,
|
|
||||||
border: '1px solid rgba(91,79,199,0.25)', fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
Admin
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-slate-500">{user.email}</p>
|
||||||
{/* Nav */}
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 28, fontSize: 13, color: PURPLE }}>
|
<div className="flex gap-2 items-center">
|
||||||
<span style={{ fontWeight: 500 }}>Users</span>
|
{!user.isApproved && (
|
||||||
<button
|
<button
|
||||||
onClick={onLogout}
|
onClick={() => handleApprove(user.id)}
|
||||||
style={{
|
disabled={busy || confirmAdminId === user.id || confirmDeleteId === user.id}
|
||||||
padding: '7px 18px', borderRadius: 99,
|
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-emerald-500 text-white hover:bg-emerald-600 disabled:opacity-50 transition-colors"
|
||||||
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
|
|
||||||
color: PURPLE_DEEP, fontSize: 12, fontWeight: 500,
|
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Sign out
|
{approvingId === user.id ? '…' : 'Approve'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div style={{
|
|
||||||
padding: '8px 36px 32px',
|
|
||||||
display: 'flex', flexDirection: 'column', gap: 18,
|
|
||||||
minHeight: 0, flex: 1, overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
{/* Hero */}
|
|
||||||
<div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
|
|
||||||
color: PURPLE, opacity: 0.7, marginBottom: 12, fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
Users · The Hague HQ
|
|
||||||
</div>
|
|
||||||
<h1 style={{
|
|
||||||
margin: 0,
|
|
||||||
fontFamily: "'Rubik Mono One', monospace",
|
|
||||||
fontSize: 42, fontWeight: 400, letterSpacing: '-0.02em',
|
|
||||||
lineHeight: 0.95, color: PURPLE,
|
|
||||||
}}>
|
|
||||||
Who can <span style={{ color: SAGE_DEEP }}>book?</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI strip */}
|
|
||||||
{!loading && (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12 }}>
|
|
||||||
{kpis.map((k) => (
|
|
||||||
<div key={k.label} style={{
|
|
||||||
background: PAPER, borderRadius: 14, padding: '14px 16px',
|
|
||||||
border: '1px solid rgba(91,79,199,0.10)',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase',
|
|
||||||
color: PURPLE, opacity: 0.7, fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
{k.label}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontFamily: "'Rubik Mono One', monospace",
|
|
||||||
fontSize: 30, letterSpacing: '-0.02em', color: PURPLE,
|
|
||||||
marginTop: 4, lineHeight: 1,
|
|
||||||
}}>
|
|
||||||
{k.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Users table card */}
|
{!user.isAdmin && confirmAdminId === user.id ? (
|
||||||
<div style={{
|
<>
|
||||||
background: PAPER, borderRadius: 18,
|
<span className="text-sm text-slate-500">Make admin?</span>
|
||||||
border: '1px solid rgba(91,79,199,0.10)',
|
|
||||||
flex: 1, display: 'flex', flexDirection: 'column',
|
|
||||||
minHeight: 0, overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
||||||
padding: '14px 18px',
|
|
||||||
borderBottom: '1px solid rgba(91,79,199,0.10)',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
|
||||||
padding: '7px 12px', borderRadius: 99,
|
|
||||||
border: '1px solid rgba(91,79,199,0.18)',
|
|
||||||
background: 'rgba(255,255,255,0.4)',
|
|
||||||
fontSize: 13, color: PURPLE, minWidth: 260,
|
|
||||||
}}>
|
|
||||||
<span style={{ opacity: 0.6 }}>⌕</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by name or email"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
style={{
|
|
||||||
background: 'none', border: 'none', outline: 'none',
|
|
||||||
fontSize: 13, color: PURPLE, fontFamily: 'inherit',
|
|
||||||
flex: 1, opacity: search ? 1 : 0.6,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { setAddForm({ name: '', email: '', password: '' }); setAddError(''); setShowAddModal(true); }}
|
onClick={() => handleMakeAdmin(user.id)}
|
||||||
style={{
|
disabled={makingAdminId === user.id}
|
||||||
padding: '8px 16px', borderRadius: 99,
|
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-50 transition-colors"
|
||||||
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
|
|
||||||
color: PURPLE_DEEP, fontSize: 12, fontWeight: 500,
|
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
+ Add user
|
{makingAdminId === user.id ? '…' : 'Yes'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<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>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table head */}
|
{confirmDeleteId === user.id ? (
|
||||||
<div style={{
|
<>
|
||||||
display: 'grid', gridTemplateColumns: TABLE_COLS,
|
<span className="text-sm text-slate-500">Sure?</span>
|
||||||
padding: '10px 18px',
|
<button
|
||||||
fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase',
|
onClick={() => handleDelete(user.id)}
|
||||||
color: PURPLE, opacity: 0.7, fontWeight: 500,
|
disabled={deletingId === user.id}
|
||||||
borderBottom: '1px solid rgba(91,79,199,0.08)',
|
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"
|
||||||
flexShrink: 0,
|
>
|
||||||
}}>
|
{deletingId === user.id ? '…' : 'Yes, delete'}
|
||||||
<span>Name</span>
|
</button>
|
||||||
<span>Email</span>
|
<button
|
||||||
<span>Role</span>
|
onClick={() => setConfirmDeleteId(null)}
|
||||||
<span>Status</span>
|
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
<span>Actions</span>
|
>
|
||||||
</div>
|
Cancel
|
||||||
|
</button>
|
||||||
{/* Table rows */}
|
</>
|
||||||
<div style={{ overflow: 'auto', flex: 1 }}>
|
) : (
|
||||||
{loading && (
|
<button
|
||||||
<p style={{ textAlign: 'center', color: PURPLE, opacity: 0.5, fontSize: 13, margin: '32px 0' }}>
|
onClick={() => setConfirmDeleteId(user.id)}
|
||||||
Loading…
|
disabled={busy || confirmAdminId === user.id}
|
||||||
</p>
|
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"
|
||||||
)}
|
>
|
||||||
{error && (
|
Delete
|
||||||
<p style={{ textAlign: 'center', color: '#c0392b', fontSize: 13, margin: '24px 0' }}>
|
</button>
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
</div>
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Add user modal */}
|
return (
|
||||||
{showAddModal && (
|
<div className="min-h-screen bg-slate-50">
|
||||||
<div
|
<header className="bg-white border-b border-slate-200 px-6 py-4 shadow-sm">
|
||||||
onClick={() => setShowAddModal(false)}
|
<div className="max-w-3xl mx-auto flex items-center justify-between">
|
||||||
style={{
|
<div>
|
||||||
position: 'fixed', inset: 0,
|
<h1 className="text-xl font-semibold text-slate-800">Admin Portal</h1>
|
||||||
background: 'rgba(63,51,168,0.18)', backdropFilter: 'blur(2px)',
|
<p className="text-xs text-slate-400 mt-0.5">Manage user accounts</p>
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
</div>
|
||||||
zIndex: 100,
|
<button
|
||||||
}}
|
onClick={() => navigate('/')}
|
||||||
>
|
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
<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={{
|
← Back to planner
|
||||||
margin: '0 0 24px',
|
</button>
|
||||||
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>
|
||||||
)}
|
</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>}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-widest mb-3">
|
||||||
|
Pending approval
|
||||||
|
</h2>
|
||||||
|
{pending.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-400">No pending accounts.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-3">
|
||||||
|
{pending.map((u) => <UserRow key={u.id} user={u} />)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-widest mb-3">
|
||||||
|
Approved accounts
|
||||||
|
</h2>
|
||||||
|
{approved.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-400">No approved accounts yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-3">
|
||||||
|
{approved.map((u) => <UserRow key={u.id} user={u} />)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,34 +6,6 @@ interface AuthPageProps {
|
|||||||
onAuth: (auth: AuthResponse) => void;
|
onAuth: (auth: AuthResponse) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PURPLE = '#5b4fc7';
|
|
||||||
const PURPLE_DEEP = '#3f33a8';
|
|
||||||
const SAGE = '#c7d4b8';
|
|
||||||
const SAGE_DEEP = '#a9bb96';
|
|
||||||
const PAPER = '#f4f3ee';
|
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
|
||||||
padding: '12px 14px',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: '1px solid rgba(91,79,199,0.18)',
|
|
||||||
background: PAPER,
|
|
||||||
color: PURPLE_DEEP,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
outline: 'none',
|
|
||||||
width: '100%',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldLabelStyle: React.CSSProperties = {
|
|
||||||
fontSize: 11,
|
|
||||||
letterSpacing: '0.14em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
color: PURPLE,
|
|
||||||
opacity: 0.7,
|
|
||||||
fontWeight: 500,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AuthPage({ onAuth }: AuthPageProps) {
|
export function AuthPage({ onAuth }: AuthPageProps) {
|
||||||
const [mode, setMode] = useState<'login' | 'register' | 'pending'>('login');
|
const [mode, setMode] = useState<'login' | 'register' | 'pending'>('login');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -42,7 +14,7 @@ export function AuthPage({ onAuth }: AuthPageProps) {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: { preventDefault: () => void }) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -62,220 +34,101 @@ export function AuthPage({ onAuth }: AuthPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--bg)', minHeight: '100vh', display: 'flex' }}>
|
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center px-4">
|
||||||
<div style={{
|
<div className="w-full max-w-sm">
|
||||||
maxWidth: 1100,
|
<div className="text-center mb-8">
|
||||||
width: '100%',
|
<h1 className="text-2xl font-semibold text-slate-800">Office Planner</h1>
|
||||||
margin: '0 auto',
|
<p className="text-sm text-slate-400 mt-1">Reserve your workspace</p>
|
||||||
height: '100vh',
|
|
||||||
display: 'flex',
|
|
||||||
position: 'relative',
|
|
||||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
|
||||||
}}>
|
|
||||||
{/* Wordmark */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', top: 22, left: 36,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 1,
|
|
||||||
color: PURPLE,
|
|
||||||
fontFamily: "'Rubik Mono One', monospace",
|
|
||||||
fontSize: 18, letterSpacing: '-0.02em',
|
|
||||||
}}>
|
|
||||||
<span style={{ opacity: 0.6 }}>{'{'}</span>
|
|
||||||
<span style={{ padding: '0 4px' }}>randall</span>
|
|
||||||
<span style={{ opacity: 0.6 }}>{'}'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Left editorial column */}
|
{mode === 'pending' && (
|
||||||
<div style={{
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8 text-center">
|
||||||
flex: 1,
|
<div className="text-3xl mb-4">⏳</div>
|
||||||
display: 'flex',
|
<h2 className="text-base font-semibold text-slate-800 mb-2">Account pending approval</h2>
|
||||||
flexDirection: 'column',
|
<p className="text-sm text-slate-500 mb-6">
|
||||||
justifyContent: 'center',
|
Your account has been created. An administrator will review and approve it shortly.
|
||||||
padding: '0 80px',
|
</p>
|
||||||
}}>
|
<button
|
||||||
<div style={{
|
onClick={() => { setMode('login'); setError(''); }}
|
||||||
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
|
className="text-sm text-emerald-600 hover:text-emerald-700 font-medium"
|
||||||
color: PURPLE, opacity: 0.7, marginBottom: 14, fontWeight: 500,
|
>
|
||||||
}}>
|
Back to sign in
|
||||||
The Hague HQ · 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">
|
||||||
|
<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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMode('register'); setError(''); }}
|
||||||
|
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
mode === 'register' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 style={{
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
margin: 0,
|
{mode === 'register' && (
|
||||||
fontFamily: "'Rubik Mono One', monospace",
|
<div>
|
||||||
fontSize: 54, fontWeight: 400,
|
<label className="block text-sm font-medium text-slate-700 mb-1">Full name</label>
|
||||||
letterSpacing: '-0.02em', lineHeight: 0.95,
|
<input
|
||||||
color: PURPLE, maxWidth: 520,
|
type="text"
|
||||||
}}>
|
required
|
||||||
Find your <span style={{ color: SAGE_DEEP }}>desk.</span>
|
value={name}
|
||||||
</h1>
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Jane Smith"
|
||||||
<p style={{
|
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"
|
||||||
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>
|
||||||
<div style={{
|
)}
|
||||||
fontFamily: "'Rubik Mono One', monospace",
|
|
||||||
fontSize: 32, color: PURPLE, lineHeight: 0.95,
|
<div>
|
||||||
letterSpacing: '-0.02em', marginBottom: 12,
|
<label className="block text-sm font-medium text-slate-700 mb-1">Work email</label>
|
||||||
}}>
|
<input
|
||||||
Almost there.
|
type="email"
|
||||||
</div>
|
required
|
||||||
<p style={{ fontSize: 13, color: PURPLE, opacity: 0.75, lineHeight: 1.5, margin: '0 0 22px' }}>
|
value={email}
|
||||||
Your account has been created. An administrator will review and approve it shortly.
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
</p>
|
placeholder="jane@company.com"
|
||||||
<button
|
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"
|
||||||
onClick={() => { setMode('login'); setError(''); }}
|
/>
|
||||||
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>
|
</div>
|
||||||
) : (
|
|
||||||
<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
|
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{m === 'login' ? 'Sign in' : 'Register'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
<div>
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<label className="block text-sm font-medium text-slate-700 mb-1">Password</label>
|
||||||
<span style={fieldLabelStyle}>Email</span>
|
<input
|
||||||
<input
|
type="password"
|
||||||
type="email"
|
required
|
||||||
required
|
value={password}
|
||||||
placeholder="you@randall.local"
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
value={email}
|
placeholder="••••••••"
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
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}
|
/>
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
<span style={fieldLabelStyle}>Password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{mode === 'register' && (
|
|
||||||
<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 && (
|
|
||||||
<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}
|
|
||||||
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 →'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<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>
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{loading ? '…' : mode === 'login' ? 'Sign in' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ import { ReservationModal } from '../components/ReservationModal';
|
|||||||
import { CancelModal } from '../components/CancelModal';
|
import { CancelModal } from '../components/CancelModal';
|
||||||
import { MyReservations } from '../components/MyReservations';
|
import { MyReservations } from '../components/MyReservations';
|
||||||
|
|
||||||
const PURPLE = '#5b4fc7';
|
|
||||||
const PURPLE_DEEP = '#3f33a8';
|
|
||||||
const SAGE = '#c7d4b8';
|
|
||||||
const SAGE_DEEP = '#a9bb96';
|
|
||||||
const PAPER = '#f4f3ee';
|
|
||||||
|
|
||||||
function toIsoDate(date: Date): string {
|
function toIsoDate(date: Date): string {
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
@@ -31,49 +25,6 @@ function formatDisplayDate(isoDate: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatKickerDate(isoDate: string): string {
|
|
||||||
const [y, m, d] = isoDate.split('-').map(Number);
|
|
||||||
const date = new Date(y, m - 1, d);
|
|
||||||
const weekday = date.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase();
|
|
||||||
const month = date.toLocaleDateString('en-GB', { month: 'short' }).toUpperCase();
|
|
||||||
return `${weekday}, ${d} ${month} · THE HAGUE HQ`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeTime(isoString: string): string {
|
|
||||||
const created = new Date(isoString);
|
|
||||||
const now = new Date();
|
|
||||||
const mins = Math.floor((now.getTime() - created.getTime()) / 60000);
|
|
||||||
if (mins < 1) return 'Just now';
|
|
||||||
if (mins < 60) return `${mins} min ago`;
|
|
||||||
const hours = Math.floor(mins / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
return 'Earlier today';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDayOffset(isoDate: string, isoToday: string): number {
|
|
||||||
const [y1, m1, d1] = isoDate.split('-').map(Number);
|
|
||||||
const [y2, m2, d2] = isoToday.split('-').map(Number);
|
|
||||||
const a = new Date(y1, m1 - 1, d1).getTime();
|
|
||||||
const b = new Date(y2, m2 - 1, d2).getTime();
|
|
||||||
return Math.round((a - b) / (1000 * 60 * 60 * 24));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTrailingWord(dayOffset: number): string {
|
|
||||||
if (dayOffset === 0) return 'today?';
|
|
||||||
if (dayOffset === 1) return 'tomorrow?';
|
|
||||||
return 'then?';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDayCell(isoDate: string): { weekday: string; day: number; monthTag: string | null } {
|
|
||||||
const [y, m, d] = isoDate.split('-').map(Number);
|
|
||||||
const date = new Date(y, m - 1, d);
|
|
||||||
const weekday = date.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase().slice(0, 3);
|
|
||||||
const monthTag = d === 1
|
|
||||||
? date.toLocaleDateString('en-GB', { month: 'short' }).toUpperCase()
|
|
||||||
: null;
|
|
||||||
return { weekday, day: d, monthTag };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PlannerPageProps {
|
interface PlannerPageProps {
|
||||||
auth: AuthResponse;
|
auth: AuthResponse;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
@@ -83,7 +34,7 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const today = toIsoDate(new Date());
|
const today = toIsoDate(new Date());
|
||||||
const MAX_OFFSET = 13;
|
const maxDate = toIsoDate(new Date(Date.now() + 14 * 24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState(today);
|
const [selectedDate, setSelectedDate] = useState(today);
|
||||||
const [schedule, setSchedule] = useState<WorkplaceScheduleItem[]>([]);
|
const [schedule, setSchedule] = useState<WorkplaceScheduleItem[]>([]);
|
||||||
@@ -114,30 +65,6 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
|
|||||||
.map((r) => r.workplaceId),
|
.map((r) => r.workplaceId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const myDeskOnDate = myReservations.find(
|
|
||||||
(r) => r.status === 'Active' && r.date === selectedDate,
|
|
||||||
);
|
|
||||||
const myDeskSchedule = myDeskOnDate
|
|
||||||
? schedule.find((w) => w.id === myDeskOnDate.workplaceId)
|
|
||||||
: null;
|
|
||||||
const neighbours = myDeskSchedule
|
|
||||||
? schedule
|
|
||||||
.filter(
|
|
||||||
(w) =>
|
|
||||||
w.location === myDeskSchedule.location &&
|
|
||||||
!w.isAvailable &&
|
|
||||||
w.id !== myDeskSchedule.id &&
|
|
||||||
w.reservedBy !== null,
|
|
||||||
)
|
|
||||||
.map((w) => w.reservedBy!.split(' ')[0])
|
|
||||||
.join(', ')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const freeCount = schedule.filter((w) => w.isAvailable).length;
|
|
||||||
const dayOffset = getDayOffset(selectedDate, today);
|
|
||||||
const trailingWord = getTrailingWord(dayOffset);
|
|
||||||
const dayStrip = Array.from({ length: 14 }, (_, i) => offsetDate(today, i));
|
|
||||||
|
|
||||||
function handleDeskClick(desk: WorkplaceScheduleItem) {
|
function handleDeskClick(desk: WorkplaceScheduleItem) {
|
||||||
if (myReservedIdsOnDate.has(desk.id)) {
|
if (myReservedIdsOnDate.has(desk.id)) {
|
||||||
const res = myReservations.find(
|
const res = myReservations.find(
|
||||||
@@ -166,380 +93,93 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
|
|||||||
const podA = schedule.filter((w) => w.location === 'Pod A');
|
const podA = schedule.filter((w) => w.location === 'Pod A');
|
||||||
const podB = schedule.filter((w) => w.location === 'Pod B');
|
const podB = schedule.filter((w) => w.location === 'Pod B');
|
||||||
|
|
||||||
const arrowBtnStyle = (disabled: boolean): React.CSSProperties => ({
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: disabled ? 'default' : 'pointer',
|
|
||||||
color: PURPLE,
|
|
||||||
opacity: disabled ? 0.35 : 0.65,
|
|
||||||
fontSize: 16,
|
|
||||||
padding: '0 4px',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
flexShrink: 0,
|
|
||||||
lineHeight: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* Outer bg fills the full viewport */
|
<div className="min-h-screen bg-slate-50">
|
||||||
<div style={{ background: 'var(--bg)', minHeight: '100vh' }}>
|
<header className="bg-white border-b border-slate-200 px-6 py-4 shadow-sm">
|
||||||
{/* Centred 1100 px frame */}
|
<div className="max-w-5xl mx-auto flex items-center justify-between">
|
||||||
<div style={{
|
<div>
|
||||||
maxWidth: 1100,
|
<h1 className="text-xl font-semibold text-slate-800">Office Planner</h1>
|
||||||
margin: '0 auto',
|
<p className="text-xs text-slate-400 mt-0.5">Reserve your workspace up to 2 weeks ahead</p>
|
||||||
height: '100vh',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
{/* Header */}
|
|
||||||
<header style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '22px 36px',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
{/* Wordmark */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
color: PURPLE,
|
|
||||||
fontFamily: "'Rubik Mono One', 'Major Mono Display', monospace",
|
|
||||||
fontSize: 18,
|
|
||||||
letterSpacing: '-0.02em',
|
|
||||||
}}>
|
|
||||||
<span style={{ fontWeight: 400, opacity: 0.6 }}>{'{'}</span>
|
|
||||||
<span style={{ padding: '0 4px' }}>randall</span>
|
|
||||||
<span style={{ fontWeight: 400, opacity: 0.6 }}>{'}'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
{/* Nav */}
|
<span className="text-sm text-slate-600">{auth.name}</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 28, fontSize: 13, color: PURPLE }}>
|
|
||||||
<span style={{ fontWeight: 500 }}>Today</span>
|
|
||||||
{auth.isAdmin && (
|
{auth.isAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin')}
|
onClick={() => navigate('/admin')}
|
||||||
style={{
|
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
background: 'none', border: 'none', fontFamily: 'inherit',
|
|
||||||
fontSize: 13, color: PURPLE, opacity: 0.6, cursor: 'pointer', padding: 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Admin
|
Admin portal
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
style={{
|
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
padding: '7px 18px',
|
|
||||||
borderRadius: 99,
|
|
||||||
background: SAGE,
|
|
||||||
border: `1px solid ${SAGE_DEEP}`,
|
|
||||||
color: PURPLE_DEEP,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
<span style={{ fontSize: 12, color: PURPLE, opacity: 0.6, cursor: 'default' }}>NL ▾</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* 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={dayOffset === 0}
|
|
||||||
style={arrowBtnStyle(dayOffset === 0)}
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
<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={dayOffset >= MAX_OFFSET}
|
|
||||||
style={arrowBtnStyle(dayOffset >= MAX_OFFSET)}
|
|
||||||
>
|
|
||||||
→
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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 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 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>
|
|
||||||
|
|
||||||
{/* Pods */}
|
|
||||||
{!loadingFloor && !floorError && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* 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} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-slate-500 font-medium">{formatDisplayDate(selectedDate)}</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</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>}
|
||||||
|
{!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>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<MyReservations reservations={myReservations} onCancel={setCancelTarget} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
{reserveTarget && (
|
{reserveTarget && (
|
||||||
<ReservationModal
|
<ReservationModal
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default defineConfig({
|
|||||||
retries: 0,
|
retries: 0,
|
||||||
reporter: [['list'], ['html', { open: 'never' }]],
|
reporter: [['list'], ['html', { open: 'never' }]],
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.BASE_URL ?? 'http://localhost:5173',
|
baseURL: process.env.BASE_URL ?? 'http://localhost',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
headless: !!process.env.CI,
|
headless: !!process.env.CI,
|
||||||
|
|||||||
@@ -6,96 +6,41 @@ test.describe('Admin portal', () => {
|
|||||||
await loginAsAdmin(page);
|
await loginAsAdmin(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('admin user sees the Admin button in the header', async ({ page }) => {
|
test('admin user sees the Admin portal button in the header', async ({ page }) => {
|
||||||
await expect(page.getByRole('button', { name: 'Admin' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Admin portal' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigates to the admin portal', async ({ page }) => {
|
test('navigates to the admin portal', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: 'Admin' }).click();
|
await page.getByRole('button', { name: 'Admin portal' }).click();
|
||||||
await page.waitForURL('/admin');
|
await page.waitForURL('/admin');
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Who can book/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Admin Portal' })).toBeVisible();
|
||||||
await expect(page.getByText(/Users.*The Hague HQ/i)).toBeVisible();
|
await expect(page.getByText('Manage user accounts')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('admin portal lists the admin account', async ({ page }) => {
|
test('admin portal lists the admin account under Approved accounts', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: 'Admin' }).click();
|
await page.goto('/admin');
|
||||||
await page.waitForURL('/admin');
|
|
||||||
|
|
||||||
await expect(page.getByText('admin@randall.local')).toBeVisible();
|
await expect(page.getByText('Approved accounts')).toBeVisible();
|
||||||
const adminRow = page.locator('[data-testid="user-row"]').filter({ hasText: 'admin@randall.local' });
|
// Target the name paragraph inside the admin's list item
|
||||||
await expect(adminRow.locator('[data-testid="role-badge"]').filter({ hasText: /^admin$/i })).toBeVisible();
|
const adminRow = page.locator('li').filter({ hasText: 'admin@randall.local' });
|
||||||
|
await expect(adminRow.getByRole('paragraph').filter({ hasText: /^Admin$/ })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('admin account has the admin role badge and no make-admin button', async ({ page }) => {
|
test('admin account has the Admin badge and no Make admin button', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: 'Admin' }).click();
|
await page.goto('/admin');
|
||||||
await page.waitForURL('/admin');
|
|
||||||
|
|
||||||
const adminRow = page.locator('[data-testid="user-row"]').filter({ hasText: 'admin@randall.local' });
|
const adminRow = page.locator('li').filter({ hasText: 'admin@randall.local' });
|
||||||
await expect(adminRow.locator('[data-testid="role-badge"]').filter({ hasText: /^admin$/i })).toBeVisible();
|
// The badge is a <span> with class bg-amber-100
|
||||||
// The make-admin action button is not rendered for users who are already admin
|
await expect(adminRow.locator('span').filter({ hasText: /^Admin$/ })).toBeVisible();
|
||||||
await expect(adminRow.getByRole('button', { name: 'Admin' })).not.toBeVisible();
|
await expect(adminRow.getByRole('button', { name: 'Make admin' })).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wordmark navigates back to the planner', async ({ page }) => {
|
test('Back to planner link returns to the planner', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: 'Admin' }).click();
|
await page.goto('/admin');
|
||||||
await page.waitForURL('/admin');
|
await page.getByRole('button', { name: '← Back to planner' }).click();
|
||||||
await page.locator('span', { hasText: 'randall' }).first().click();
|
|
||||||
|
|
||||||
await page.waitForURL('/');
|
await page.waitForURL('/');
|
||||||
await expect(page.getByRole('heading', { name: /Where to sit/i })).toBeVisible();
|
await expect(page.getByText('Reserve your workspace up to 2 weeks ahead')).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test('+ Add user button opens the add-user modal', async ({ page }) => {
|
|
||||||
await page.getByRole('button', { name: 'Admin' }).click();
|
|
||||||
await page.waitForURL('/admin');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: '+ Add user' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add user' })).toBeVisible();
|
|
||||||
await expect(page.locator('form input[type="text"]')).toBeVisible();
|
|
||||||
await expect(page.locator('form input[type="email"]')).toBeVisible();
|
|
||||||
await expect(page.locator('form input[type="password"]')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('add-user modal closes when Cancel is clicked', async ({ page }) => {
|
|
||||||
await page.getByRole('button', { name: 'Admin' }).click();
|
|
||||||
await page.waitForURL('/admin');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: '+ Add user' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add user' })).toBeVisible();
|
|
||||||
|
|
||||||
await page.locator('form').getByRole('button', { name: 'Cancel' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add user' })).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('can add a new user via the modal and see them in the table', async ({ page }) => {
|
|
||||||
await page.getByRole('button', { name: 'Admin' }).click();
|
|
||||||
await page.waitForURL('/admin');
|
|
||||||
|
|
||||||
const email = `adduser+${Date.now()}@test.com`;
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: '+ Add user' }).click();
|
|
||||||
await page.locator('form input[type="text"]').fill('New Test User');
|
|
||||||
await page.locator('form input[type="email"]').fill(email);
|
|
||||||
await page.locator('form input[type="password"]').fill('Test@1234');
|
|
||||||
await page.locator('form button[type="submit"]').click();
|
|
||||||
|
|
||||||
await expect(page.getByText(email)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('add-user modal shows an error for a duplicate email', async ({ page }) => {
|
|
||||||
await page.getByRole('button', { name: 'Admin' }).click();
|
|
||||||
await page.waitForURL('/admin');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: '+ Add user' }).click();
|
|
||||||
await page.locator('form input[type="text"]').fill('Duplicate');
|
|
||||||
await page.locator('form input[type="email"]').fill('admin@randall.local');
|
|
||||||
await page.locator('form input[type="password"]').fill('Test@1234');
|
|
||||||
await page.locator('form button[type="submit"]').click();
|
|
||||||
|
|
||||||
await expect(page.getByText(/already exists/i)).toBeVisible();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add user' })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,22 +7,22 @@ test.describe('Authentication', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('shows the sign-in form on initial load', async ({ page }) => {
|
test('shows the sign-in form on initial load', async ({ page }) => {
|
||||||
await expect(page.getByRole('heading', { name: /Find your desk/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Office Planner' })).toBeVisible();
|
||||||
await expect(page.getByPlaceholder('you@randall.local')).toBeVisible();
|
await expect(page.getByPlaceholder('jane@company.com')).toBeVisible();
|
||||||
await expect(page.getByPlaceholder('••••••••')).toBeVisible();
|
await expect(page.getByPlaceholder('••••••••')).toBeVisible();
|
||||||
await expect(page.locator('button[type="submit"]')).toHaveText('Sign in →');
|
await expect(page.locator('button[type="submit"]')).toHaveText('Sign in');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('signs in with valid credentials and reaches the planner', async ({ page }) => {
|
test('signs in with valid credentials and reaches the planner', async ({ page }) => {
|
||||||
await page.getByPlaceholder('you@randall.local').fill(ADMIN_EMAIL);
|
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
|
||||||
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
|
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
|
||||||
await page.locator('button[type="submit"]').click();
|
await page.locator('button[type="submit"]').click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Where to sit/i })).toBeVisible();
|
await expect(page.getByText('Reserve your workspace up to 2 weeks ahead')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows an error for an unknown email', async ({ page }) => {
|
test('shows an error for an unknown email', async ({ page }) => {
|
||||||
await page.getByPlaceholder('you@randall.local').fill('nobody@example.com');
|
await page.getByPlaceholder('jane@company.com').fill('nobody@example.com');
|
||||||
await page.getByPlaceholder('••••••••').fill('anything');
|
await page.getByPlaceholder('••••••••').fill('anything');
|
||||||
await page.locator('button[type="submit"]').click();
|
await page.locator('button[type="submit"]').click();
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ test.describe('Authentication', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('shows an error for a wrong password', async ({ page }) => {
|
test('shows an error for a wrong password', async ({ page }) => {
|
||||||
await page.getByPlaceholder('you@randall.local').fill(ADMIN_EMAIL);
|
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
|
||||||
await page.getByPlaceholder('••••••••').fill('wrongpassword');
|
await page.getByPlaceholder('••••••••').fill('wrongpassword');
|
||||||
await page.locator('button[type="submit"]').click();
|
await page.locator('button[type="submit"]').click();
|
||||||
|
|
||||||
@@ -39,25 +39,25 @@ test.describe('Authentication', () => {
|
|||||||
|
|
||||||
test('registers a new account and shows pending approval message', async ({ page }) => {
|
test('registers a new account and shows pending approval message', async ({ page }) => {
|
||||||
// Switch to register mode
|
// Switch to register mode
|
||||||
await page.locator('button').filter({ hasText: 'Register' }).first().click();
|
await page.locator('button').filter({ hasText: 'Create account' }).first().click();
|
||||||
|
|
||||||
await page.getByPlaceholder('you@randall.local').fill(`testuser+${Date.now()}@example.com`);
|
|
||||||
await page.getByPlaceholder('••••••••').fill('Test@1234');
|
|
||||||
await page.getByPlaceholder('Jane Smith').fill('Test User');
|
await page.getByPlaceholder('Jane Smith').fill('Test User');
|
||||||
|
await page.getByPlaceholder('jane@company.com').fill(`testuser+${Date.now()}@example.com`);
|
||||||
|
await page.getByPlaceholder('••••••••').fill('Test@1234');
|
||||||
await page.locator('button[type="submit"]').click();
|
await page.locator('button[type="submit"]').click();
|
||||||
|
|
||||||
await expect(page.getByText('Almost there.')).toBeVisible();
|
await expect(page.getByText('Account pending approval')).toBeVisible();
|
||||||
await expect(page.getByText(/administrator will review/i)).toBeVisible();
|
await expect(page.getByText(/administrator will review/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('signs out and returns to the sign-in form', async ({ page }) => {
|
test('signs out and returns to the sign-in form', async ({ page }) => {
|
||||||
await page.getByPlaceholder('you@randall.local').fill(ADMIN_EMAIL);
|
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
|
||||||
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
|
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
|
||||||
await page.locator('button[type="submit"]').click();
|
await page.locator('button[type="submit"]').click();
|
||||||
await page.waitForURL('/');
|
await page.waitForURL('/');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Sign out' }).click();
|
await page.getByRole('button', { name: 'Sign out' }).click();
|
||||||
|
|
||||||
await expect(page.locator('button[type="submit"]')).toHaveText('Sign in →');
|
await expect(page.locator('button[type="submit"]')).toHaveText('Sign in');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,25 +6,20 @@ export const ADMIN_NAME = 'Admin';
|
|||||||
|
|
||||||
export async function loginAs(page: Page, email: string, password: string) {
|
export async function loginAs(page: Page, email: string, password: string) {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByPlaceholder('you@randall.local').fill(email);
|
await page.getByPlaceholder('jane@company.com').fill(email);
|
||||||
await page.getByPlaceholder('••••••••').fill(password);
|
await page.getByPlaceholder('••••••••').fill(password);
|
||||||
await page.locator('button[type="submit"]').click();
|
await page.locator('button[type="submit"]').click();
|
||||||
await page.waitForURL('/');
|
await page.waitForURL('/');
|
||||||
await page.locator('h1').waitFor();
|
await page.getByText('Reserve your workspace up to 2 weeks ahead').waitFor();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginAsAdmin(page: Page) {
|
export async function loginAsAdmin(page: Page) {
|
||||||
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns today's date offset by `days` in yyyy-MM-dd format. */
|
/** Returns today's date offset by `days` in yyyy-MM-dd format */
|
||||||
export function offsetDate(days: number): string {
|
export function offsetDate(days: number): string {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setDate(d.getDate() + days);
|
d.setDate(d.getDate() + days);
|
||||||
return d.toISOString().split('T')[0];
|
return d.toISOString().split('T')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clicks the day-strip cell for today + `days` offset. */
|
|
||||||
export async function selectDate(page: Page, days: number) {
|
|
||||||
await page.locator(`[data-date="${offsetDate(days)}"]`).click();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { loginAsAdmin, offsetDate, selectDate } from './helpers';
|
import { loginAsAdmin, offsetDate } from './helpers';
|
||||||
|
|
||||||
test.describe('Planner', () => {
|
test.describe('Planner', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
@@ -8,24 +8,20 @@ test.describe('Planner', () => {
|
|||||||
|
|
||||||
test('displays both pods with 8 desks each', async ({ page }) => {
|
test('displays both pods with 8 desks each', async ({ page }) => {
|
||||||
// Wait for floor plan to load — at least one free desk must be visible
|
// Wait for floor plan to load — at least one free desk must be visible
|
||||||
await expect(page.getByRole('button', { name: /^D1 free$/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'D1 Free' })).toBeVisible();
|
||||||
|
|
||||||
// All 16 desk buttons contain a desk label (D1–D16) in their text content
|
// 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);
|
await expect(allDesks).toHaveCount(16);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('date strip shows 14 days and respects boundaries', async ({ page }) => {
|
test('date picker is constrained to today and 14 days ahead', async ({ page }) => {
|
||||||
// 14 day-strip cells are rendered (one per bookable day)
|
const input = page.locator('input[type="date"]');
|
||||||
await expect(page.locator('[data-date]')).toHaveCount(14);
|
const today = offsetDate(0);
|
||||||
|
const max = offsetDate(14);
|
||||||
|
|
||||||
// Today and the last bookable day are both present
|
await expect(input).toHaveAttribute('min', today);
|
||||||
await expect(page.locator(`[data-date="${offsetDate(0)}"]`)).toBeVisible();
|
await expect(input).toHaveAttribute('max', max);
|
||||||
await expect(page.locator(`[data-date="${offsetDate(13)}"]`)).toBeVisible();
|
|
||||||
|
|
||||||
// Clicking the last day disables the forward arrow
|
|
||||||
await page.locator(`[data-date="${offsetDate(13)}"]`).click();
|
|
||||||
await expect(page.getByRole('button', { name: '→' })).toBeDisabled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('previous-day button is disabled when on today', async ({ page }) => {
|
test('previous-day button is disabled when on today', async ({ page }) => {
|
||||||
@@ -33,60 +29,72 @@ test.describe('Planner', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('next-day button is disabled when on the maximum date', async ({ page }) => {
|
test('next-day button is disabled when on the maximum date', async ({ page }) => {
|
||||||
await page.locator(`[data-date="${offsetDate(13)}"]`).click();
|
const input = page.locator('input[type="date"]');
|
||||||
|
await input.fill(offsetDate(14));
|
||||||
|
await input.dispatchEvent('change');
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: '→' })).toBeDisabled();
|
await expect(page.getByRole('button', { name: '→' })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reserves a desk and shows it as yours', async ({ page }) => {
|
test('reserves a desk and shows it as Mine', async ({ page }) => {
|
||||||
await selectDate(page, 7);
|
const targetDate = offsetDate(7);
|
||||||
|
const input = page.locator('input[type="date"]');
|
||||||
|
await input.fill(targetDate);
|
||||||
|
await input.dispatchEvent('change');
|
||||||
|
|
||||||
await page.getByRole('button', { name: /^D1 free$/i }).click();
|
await page.getByRole('button', { name: 'D1 Free' }).click();
|
||||||
await expect(page.getByRole('dialog').getByText('Reserve desk')).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Reserve desk' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: /^D1 yours$/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: /^D1\s+Mine/ })).toBeVisible();
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
await page.getByRole('button', { name: /^D1 yours$/i }).click();
|
await page.getByRole('button', { name: /^D1\s+Mine/ }).click();
|
||||||
await page.getByRole('button', { name: 'Cancel reservation' }).click();
|
await page.getByRole('button', { name: 'Cancel reservation' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reserved desk appears in My reservations', async ({ page }) => {
|
test('reserved desk appears in My reservations', async ({ page }) => {
|
||||||
await selectDate(page, 8);
|
const targetDate = offsetDate(8);
|
||||||
|
const input = page.locator('input[type="date"]');
|
||||||
|
await input.fill(targetDate);
|
||||||
|
await input.dispatchEvent('change');
|
||||||
|
|
||||||
await page.getByRole('button', { name: /D2.*free/i }).click();
|
await page.getByRole('button', { name: 'D2 Free' }).click();
|
||||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
|
||||||
await expect(page.getByText('My reservations')).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'My reservations' })).toBeVisible();
|
||||||
await expect(page.locator('li').filter({ hasText: 'D2' })).toBeVisible();
|
await expect(page.locator('section').filter({ hasText: 'My reservations' }).getByText('D2')).toBeVisible();
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
await page.getByRole('button', { name: /D2.*yours/i }).click();
|
await page.getByRole('button', { name: /^D2\s+Mine/ }).click();
|
||||||
await page.getByRole('button', { name: 'Cancel reservation' }).click();
|
await page.getByRole('button', { name: 'Cancel reservation' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cancels a reservation and desk returns to free', async ({ page }) => {
|
test('cancels a reservation and desk returns to Free', async ({ page }) => {
|
||||||
await selectDate(page, 9);
|
const targetDate = offsetDate(9);
|
||||||
|
const input = page.locator('input[type="date"]');
|
||||||
|
await input.fill(targetDate);
|
||||||
|
await input.dispatchEvent('change');
|
||||||
|
|
||||||
// Reserve
|
// Reserve
|
||||||
await page.getByRole('button', { name: /D3.*free/i }).click();
|
await page.getByRole('button', { name: 'D3 Free' }).click();
|
||||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
await expect(page.getByRole('button', { name: /D3.*yours/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: /^D3\s+Mine/ })).toBeVisible();
|
||||||
|
|
||||||
// Cancel
|
// Cancel
|
||||||
await page.getByRole('button', { name: /D3.*yours/i }).click();
|
await page.getByRole('button', { name: /^D3\s+Mine/ }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Cancel reservation' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Cancel reservation' }).click();
|
await page.getByRole('button', { name: 'Cancel reservation' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: /D3.*free/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'D3 Free' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dismisses the reservation modal when clicking Cancel', async ({ page }) => {
|
test('dismisses the reservation modal when clicking Cancel', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: /D4.*free/i }).click();
|
await page.getByRole('button', { name: 'D4 Free' }).click();
|
||||||
await expect(page.getByRole('dialog').getByText('Reserve desk')).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Reserve desk' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();
|
// Scope to the modal overlay to avoid matching Cancel buttons in My reservations
|
||||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
await page.locator('.fixed.inset-0').getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Reserve desk' })).not.toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user