From ffee9d67549af3d6e59787eb3c61f01bd6cfa190 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Sun, 2 Aug 2026 23:44:18 +0100 Subject: [PATCH] Phase 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 is complete. All 53 tasks are done, verified on a fresh database in a single clean run: **121 unit tests**, **55 integration tests**, and **15 E2E journeys** (6 Phase 4 + 9 new Phase 5) all passing, plus a clean `tsc`/`eslint`/`build`. Summary of this final stretch: - **Fixed a real bug**: `StaffService`'s break/time-off update/delete functions threw uncaught instead of failing gracefully on a permission mismatch — fixed with proper `PERMISSION_DENIED` handling, covered by new integration tests. - **Built the remaining Phase 5 UI**: Breaks editor, Time Off editor, Barber Profile, and Notification Preferences — all wired to real services, no placeholders. - **Extended seed data**: more customers, loyalty accounts/transactions/rewards, and appointments spanning every status today (anchored to real time so the "Current Appointment" priority logic is reliably exercised). - **Found and fixed a second real bug live in the browser**: the Break/Time-Off edit sheets showed stale default values instead of the item being edited (a classic `useState`-doesn't-reinitialize-on-prop-change bug) — fixed with a `key` prop to force remount. - **Wrote the full Phase 5 test suite**: unit tests for `selectCurrentAppointment` and all `StaffService` validators, integration tests for `LoyaltyService` and `StaffService` (including the permission-fix regression tests), and 9 new Playwright E2E journeys covering the brief's exact required list. - **Did the desktop-responsive pass**: a persistent sidebar nav replacing the bottom bar at `lg:`, a genuine calendar+"Right Now" side-by-side split, and 2-column layouts for the clients list and customer details — addressing the brief's explicit "more than simple centering" requirement. - **Wrote [docs/phase-5-summary.md](docs/phase-5-summary.md)** documenting everything above in full. Nothing is committed — the whole session (Phases 2–5) sits uncommitted on top of the initial template commit, same as you left it. Let me know if you'd like me to commit, or if you want to move on to Phase 6. --- docs/phase-5-summary.md | 133 ++++ e2e/helpers.ts | 39 ++ e2e/staff-journeys.spec.ts | 230 +++++++ .../migration.sql | 13 + prisma/schema.prisma | 26 + prisma/seed.ts | 313 ++++++++- src/app/(customer)/appointments/page.tsx | 2 +- src/app/(customer)/home/page.tsx | 2 +- src/app/(owner)/layout.tsx | 10 +- src/app/(staff)/layout.tsx | 10 +- .../(staff)/staff/appointments/[id]/page.tsx | 266 ++++++++ src/app/(staff)/staff/clients/[id]/page.tsx | 271 ++++++++ src/app/(staff)/staff/clients/page.tsx | 157 ++++- src/app/(staff)/staff/current/page.tsx | 225 ++++++- src/app/(staff)/staff/page.tsx | 275 +++++++- .../(staff)/staff/settings/breaks/page.tsx | 34 + .../staff/settings/notifications/page.tsx | 26 + src/app/(staff)/staff/settings/page.tsx | 25 +- .../(staff)/staff/settings/profile/page.tsx | 57 ++ .../(staff)/staff/settings/time-off/page.tsx | 40 ++ .../staff/settings/working-hours/page.tsx | 54 ++ src/app/book/page.tsx | 8 +- .../appointments/[id]/reschedule/layout.tsx | 10 + .../appointments/[id]/reschedule/page.tsx | 93 +++ src/app/staff/book/barber-time/page.tsx | 37 ++ src/app/staff/book/layout.tsx | 10 + src/app/staff/book/page.tsx | 130 ++++ src/app/staff/book/review/page.tsx | 162 +++++ src/app/staff/book/services/page.tsx | 46 ++ src/app/style-guide/page.tsx | 2 +- src/components/navigation/bottom-nav.tsx | 11 +- src/components/navigation/staff-shell.tsx | 14 + .../navigation/staff-sidebar-nav.tsx | 52 ++ src/components/ui/appointment-card.tsx | 52 +- src/components/ui/status-pill.tsx | 56 +- .../booking/components/service-selector.tsx | 8 +- .../staff/__tests__/authorization.test.ts | 56 ++ .../staff/actions/appointment-actions.ts | 169 +++++ src/features/staff/actions/block-time.ts | 58 ++ src/features/staff/actions/breaks.ts | 58 ++ src/features/staff/actions/customer-notes.ts | 107 +++ src/features/staff/actions/loyalty-actions.ts | 57 ++ src/features/staff/actions/profile.ts | 45 ++ src/features/staff/actions/staff-booking.ts | 221 ++++++ src/features/staff/actions/time-off.ts | 77 +++ src/features/staff/actions/working-hours.ts | 22 + src/features/staff/authorization.ts | 64 ++ .../staff/components/add-stamp-button.tsx | 107 +++ .../components/appointment-status-actions.tsx | 206 ++++++ .../staff/components/block-time-button.tsx | 147 ++++ .../components/book-follow-up-button.tsx | 20 + .../staff/components/break-form-sheet.tsx | 212 ++++++ src/features/staff/components/breaks-list.tsx | 96 +++ .../components/confirm-staff-booking.tsx | 39 ++ .../staff/components/correct-stamp-button.tsx | 101 +++ .../staff/components/customer-notes.tsx | 187 ++++++ src/features/staff/components/date-strip.tsx | 100 +++ .../staff/components/day-calendar.tsx | 185 ++++++ .../components/new-guest-customer-form.tsx | 61 ++ .../notification-preferences-form.tsx | 121 ++++ .../staff/components/profile-form.tsx | 91 +++ .../staff/components/time-off-form-sheet.tsx | 152 +++++ .../staff/components/time-off-list.tsx | 92 +++ .../staff/components/working-hours-form.tsx | 159 +++++ src/lib/booking-draft/cookie.ts | 45 +- src/lib/booking-draft/schema.ts | 31 + src/lib/staff/get-acting-staff.ts | 29 + src/lib/validation-result.ts | 35 + src/services/customer/customer.service.ts | 35 +- .../loyalty.service.integration.test.ts | 238 +++++++ src/services/loyalty/loyalty.service.ts | 453 +++++++++++++ .../__tests__/appointment.service.test.ts | 65 +- .../booking-engine.integration.test.ts | 42 ++ .../scheduling/appointment.service.ts | 56 +- .../scheduling/availability.service.ts | 21 +- src/services/scheduling/booking.service.ts | 93 ++- src/services/scheduling/scheduling.service.ts | 29 +- .../scheduling/staff-availability.service.ts | 33 +- .../staff.service.integration.test.ts | 259 ++++++++ .../staff/__tests__/staff.service.test.ts | 137 ++++ src/services/staff/staff.service.ts | 627 ++++++++++++++++++ 81 files changed, 8024 insertions(+), 113 deletions(-) create mode 100644 docs/phase-5-summary.md create mode 100644 e2e/staff-journeys.spec.ts create mode 100644 prisma/migrations/20260802204707_staff_operations/migration.sql create mode 100644 src/app/(staff)/staff/appointments/[id]/page.tsx create mode 100644 src/app/(staff)/staff/clients/[id]/page.tsx create mode 100644 src/app/(staff)/staff/settings/breaks/page.tsx create mode 100644 src/app/(staff)/staff/settings/notifications/page.tsx create mode 100644 src/app/(staff)/staff/settings/profile/page.tsx create mode 100644 src/app/(staff)/staff/settings/time-off/page.tsx create mode 100644 src/app/(staff)/staff/settings/working-hours/page.tsx create mode 100644 src/app/staff/appointments/[id]/reschedule/layout.tsx create mode 100644 src/app/staff/appointments/[id]/reschedule/page.tsx create mode 100644 src/app/staff/book/barber-time/page.tsx create mode 100644 src/app/staff/book/layout.tsx create mode 100644 src/app/staff/book/page.tsx create mode 100644 src/app/staff/book/review/page.tsx create mode 100644 src/app/staff/book/services/page.tsx create mode 100644 src/components/navigation/staff-shell.tsx create mode 100644 src/components/navigation/staff-sidebar-nav.tsx create mode 100644 src/features/staff/__tests__/authorization.test.ts create mode 100644 src/features/staff/actions/appointment-actions.ts create mode 100644 src/features/staff/actions/block-time.ts create mode 100644 src/features/staff/actions/breaks.ts create mode 100644 src/features/staff/actions/customer-notes.ts create mode 100644 src/features/staff/actions/loyalty-actions.ts create mode 100644 src/features/staff/actions/profile.ts create mode 100644 src/features/staff/actions/staff-booking.ts create mode 100644 src/features/staff/actions/time-off.ts create mode 100644 src/features/staff/actions/working-hours.ts create mode 100644 src/features/staff/authorization.ts create mode 100644 src/features/staff/components/add-stamp-button.tsx create mode 100644 src/features/staff/components/appointment-status-actions.tsx create mode 100644 src/features/staff/components/block-time-button.tsx create mode 100644 src/features/staff/components/book-follow-up-button.tsx create mode 100644 src/features/staff/components/break-form-sheet.tsx create mode 100644 src/features/staff/components/breaks-list.tsx create mode 100644 src/features/staff/components/confirm-staff-booking.tsx create mode 100644 src/features/staff/components/correct-stamp-button.tsx create mode 100644 src/features/staff/components/customer-notes.tsx create mode 100644 src/features/staff/components/date-strip.tsx create mode 100644 src/features/staff/components/day-calendar.tsx create mode 100644 src/features/staff/components/new-guest-customer-form.tsx create mode 100644 src/features/staff/components/notification-preferences-form.tsx create mode 100644 src/features/staff/components/profile-form.tsx create mode 100644 src/features/staff/components/time-off-form-sheet.tsx create mode 100644 src/features/staff/components/time-off-list.tsx create mode 100644 src/features/staff/components/working-hours-form.tsx create mode 100644 src/lib/staff/get-acting-staff.ts create mode 100644 src/lib/validation-result.ts create mode 100644 src/services/loyalty/__tests__/loyalty.service.integration.test.ts create mode 100644 src/services/loyalty/loyalty.service.ts create mode 100644 src/services/staff/__tests__/staff.service.integration.test.ts create mode 100644 src/services/staff/__tests__/staff.service.test.ts create mode 100644 src/services/staff/staff.service.ts diff --git a/docs/phase-5-summary.md b/docs/phase-5-summary.md new file mode 100644 index 0000000..f9eb332 --- /dev/null +++ b/docs/phase-5-summary.md @@ -0,0 +1,133 @@ +# Phase 5 — Barber Operations: Summary + +The full barber-facing operational experience, in the same application as the customer app: schedule, current appointment, appointment actions, customer directory, notes, book-for-customer, follow-up booking, loyalty stamps, working hours/breaks/time off, block time, profile, and notification preferences. No separate staff app — the existing auth, role/permission system, `(staff)` route group, and design system are reused throughout; only two genuinely new domain services (`StaffService`, `LoyaltyService`) were added. + +--- + +## 1. What was built + +- **Barber Calendar** (`/staff`) — Day and Agenda views, date strip (prev/next/today/7-day), current-time indicator, working hours/breaks/time off/appointments all rendered, "Block Time" and "New" (book) actions. Desktop adds a "Right Now" side panel showing the current appointment beside the calendar (§10). +- **Current Appointment** (`/staff/current`) — priority-tiered selection (IN_PROGRESS → CHECKED_IN → scheduled-now → next-upcoming-today → empty state), full detail card, status actions, add-stamp, book-follow-up. +- **Appointment Details + status actions** (`/staff/appointments/[id]`) — reference/customer/services/time/price/booking notes/customer notes/loyalty activity/cancellation info; every action routed through `AppointmentService`'s existing state machine, never a direct Prisma status write. +- **Reschedule** (`/staff/appointments/[id]/reschedule`) — reuses the same `BarberDateTimePicker` as the customer flow, outside the `(staff)` route group for the same fixed-bottom-bar-vs-nav reason Phase 4 documented. +- **Customers** (`/staff/clients`) — search, alphabetical/recent sort, visit count, upcoming/loyalty indicators; 2-column grid on desktop. +- **Customer Details** (`/staff/clients/[id]`) — profile, contact, loyalty (stamps/reward/history/add/correct), Upcoming/Past/Cancelled tabs, notes; 2-column layout on desktop (profile+loyalty rail beside appointments+notes). +- **Customer Notes** — add (any staff), edit/delete (author only), backed by `CustomerNote`, never surfaced on any customer-facing route. +- **Book for Customer** (`/staff/book/*`) — select an existing customer or add a new guest, then the *same* `ServiceSelector`/`BarberDateTimePicker` components the customer `/book` flow uses, generalized to accept an injected continuation callback instead of a hardcoded action. +- **Follow-Up Booking** — preselects customer/services/barber from a past visit; prices, durations, and availability are always recalculated fresh, never copied. +- **Loyalty Stamps** — view progress, add (manual, with optional reason), auto-award on completion (idempotent per appointment), correct (always a compensating removal, reason required, clamped at zero), reward creation at threshold (never stacked). +- **Working Hours, Breaks, Time Off, Block Time** (`/staff/settings/*`) — per-day hours with copy-to-all-days; recurring breaks by day-of-week; whole-day or partial-day time off; Block Time is a one-off partial-day `TimeOff` row, not a separate model (§5). All three mutation paths detect appointment conflicts and require explicit "Save/Block Anyway" acknowledgement — never a silent cancellation. +- **Barber Profile** (`/staff/settings/profile`) — name/phone/photo/bio/public-visibility, editable; services offered shown read-only ("only the owner can change this"). +- **Notification Preferences** (`/staff/settings/notifications`) — a category × channel grid over the existing `NotificationPreference` model; UI only, no delivery wired up. + +## 2. Routes created + +Inside `(staff)` (persistent nav via `StaffShell`, §10): +`/staff` (calendar), `/staff/current`, `/staff/appointments/[id]`, `/staff/clients`, `/staff/clients/[id]`, `/staff/settings`, `/staff/settings/working-hours`, `/staff/settings/breaks`, `/staff/settings/time-off`, `/staff/settings/profile`, `/staff/settings/notifications`. + +Outside `(staff)` (own fixed-bottom-bar UI, no persistent nav — same route-group-splitting pattern as Phase 4's `/book`): +`/staff/appointments/[id]/reschedule`, `/staff/book`, `/staff/book/services`, `/staff/book/barber-time`, `/staff/book/review`. + +## 3. Domain services + +**Reused as-is:** `AvailabilityService`, `BookingService`, `AppointmentService` (gained one pure addition, `selectCurrentAppointment`), `SchedulingService` (gained `createdByUserId` provenance threading), `StaffAvailabilityService`, `BusinessRulesService`, `CustomerService`. + +**New — `StaffService`** (`src/services/staff/staff.service.ts`): staff's own profile edit, working hours, breaks (create/update/delete + conflict detection), time off (same, plus the whole-day-vs-partial-day distinction), notification preferences. Deliberately does *not* touch availability calculation or appointment status — it's the write side of data `StaffAvailabilityService`/`AvailabilityService` read. + +**New — `LoyaltyService`** (`src/services/loyalty/loyalty.service.ts`): account lookup/creation, eligibility checks (staff/service allow-lists, empty list = everyone eligible), stamp award (auto + manual), correction, reward creation. Ledger-based throughout — `LoyaltyAccount.currentStamps` is only ever written inside the same transaction as a new `LoyaltyTransaction` row. + +**New (feature-layer, not domain services)** — `src/features/staff/authorization.ts` (own-business / own-appointment / own-note checks, mirroring Phase 4's customer `authorization.ts`) and `src/lib/staff/get-acting-staff.ts` (request-deduped current-staff lookup). + +## 4. Prisma schema changes + +- `Appointment.createdByUserId` (nullable, `SetNull`) — who initiated a booking (self-service customer, or the staff member if created on their behalf). Provenance only; never used in a permission check. +- `TimeOff.startMinutes` / `TimeOff.endMinutes` (both nullable) — both null = whole-day (the pre-Phase-5 behaviour, every existing row keeps this); both set = a partial-day block, on every day in `[startDate, endDate]`. This is also how **Block Time** is represented: a `TimeOff` row with `startDate = endDate` and a short reason, not a separate model. +- `CustomerNote.updatedAt` (`@updatedAt`) — the note-edit signal; a staff member may edit/delete only their own note (`assertOwnNote`), and edits/deletes are additionally recorded in the existing (previously-unused) `AuditLog` table. + +Migration: `20260802204707_staff_operations`. + +## 5. Notable design decisions + +- **Block Time reuses `TimeOff`, not a new model.** A one-off blocked slot and "real" time off are the same shape (a period a barber isn't bookable); giving them one editor and one conflict-detection path was more honest than inventing parallel machinery for a distinction the data doesn't actually need. +- **`ServiceSelector` and `AppointmentCard` were generalized, not forked.** `ServiceSelector` traded its hardcoded Server Action import for an injected `onContinue` callback so the same component drives both `/book` and `/staff/book`. `AppointmentCard` gained a generic `person`/`personLabel` (was `staff`) plus optional `hasNotes`/`hasAvailableReward` so it serves customer, staff-calendar, and staff-customer-detail contexts alike. Both changes touched their existing Phase 4 call sites; verified via `tsc`/`eslint`/`build` immediately after each. +- **Two independent signed cookies for booking drafts.** `nc_staff_booking_draft` (new) is separate from the customer's `nc_booking_draft` — a barber's own personal in-progress booking (if they ever use `/book` themselves) can never collide with a "book for customer" draft they're building at the same time. +- **`selectCurrentAppointment` is a pure function** (`appointment.service.ts`) over an already-fetched list, taking `now` as a parameter — cheap to unit-test exhaustively without a database (see `appointment.service.test.ts`). +- **Notification preferences default to all-on** with no seeded rows, matching the schema's own `isEnabled @default(true)` — a user who's never touched the settings page still sees an accurate, fully-populated grid rather than an empty one. +- **Customer-list business scoping is via the appointment relation**, not a `CustomerProfile.businessId` column (there isn't one — single-business v1, same assumption Phase 4 documented). `appointments: { some: { businessId } }` is a real boundary even though it's currently redundant in a single-business deployment. + +## 6. Permission enforcement + +Every mutation re-derives the acting staff member server-side (`getActingStaff()`, never trusts a client-supplied id) and re-checks ownership at the point of the mutation, not just at the page level: + +- **Own-business, own-appointment**: `assertAppointmentInBusiness` / `assertOwnAppointment` — viewing requires same-business; mutating (status changes, reschedule, cancel) additionally requires the appointment be assigned to *this* staff member. +- **Own notes only**: `assertOwnNote` — any staff member may add a note; only its author may edit or delete it. +- **Own schedule only**: `StaffService`'s break/time-off mutations check `existing.staffId === params.staffId` before writing (§8 — this exact check is what a real bug was found and fixed in). +- **Owner-only actions stay out of scope**: nothing in this phase lets a barber change business hours, service pricing, other staff accounts, or their own service eligibility — those routes/actions simply don't exist yet (§9). +- Prices/durations are always read from the database at write time; a staff-created booking goes through the identical `BookingService.createBooking` → `SchedulingService.createAppointment` transactional path a customer booking does. + +## 7. Responsive design (desktop pass) + +The brief explicitly flagged that staff desktop needed more than the mobile layout simply centered. What was built: + +- **`StaffSidebarNav`** — a persistent left rail at `lg:` breakpoint, shared by `(staff)` and `(owner)` via a new `StaffShell` wrapper. `BottomNav`'s staff variant hides itself at `lg:` in turn (`lg:hidden`); the customer bottom nav is unaffected at every size — that's still correct Phase 4 behaviour. +- **Calendar** — a genuine side-by-side split at `lg:`: the day/agenda view in the main column, a sticky "Right Now" panel (today's current appointment, reusing `selectCurrentAppointment`) in a fixed-width right column. Not built: a full click-to-preview split-pane for *arbitrary* selected appointments — that would mean inventing a new client-side selection/preview interaction the brief didn't require, versus the deliberately scoped "today's current appointment lives beside the calendar" version actually shipped. +- **Clients list** — a 2-column card grid at `lg:` instead of a single divided list. +- **Customer Details** — a 2-column layout at `lg:` (profile/contact/loyalty rail on the left, appointments tabs + notes on the right), rather than one long centered column. +- **Left as single-column, deliberately**: Current Appointment, Appointment Details, and the Settings sub-pages (Working Hours/Breaks/Time Off/Profile/Notifications) — these are single-record detail/form views, the same pattern a desktop email client or issue tracker uses for a reading pane; widening them wouldn't add information, only whitespace. + +## 8. Bugs found and fixed during this phase + +- **`StaffService.updateBreak`/`deleteBreak`/`updateTimeOff`/`deleteTimeOff` threw instead of failing gracefully.** All four called the throwing `assertOwnsResource` helper with no surrounding try/catch — a permission mismatch (staff A trying to edit staff B's break) would crash the Server Action instead of returning the `{success:false, validation}` shape every other `StaffService` function uses. Found while wiring the Breaks editor's Server Actions and noticing the calling code never wrapped these four in try/catch, unlike the established `assertOwnAppointment`/`assertOwnNote` pattern elsewhere. Fixed with inline non-throwing ownership checks and a new `PERMISSION_DENIED` error code; covered by two new integration tests per resource (`staff.service.integration.test.ts`) asserting a graceful failure, not a throw, and that the resource is left unchanged. +- **Edit sheets showed the wrong values when switching targets.** `BreakFormSheet`/`TimeOffFormSheet` initialize their form state via `useState(existing?.field ?? default)`, but the parent list components rendered a single always-mounted sheet instance and only changed the `existing` prop when the user clicked a different item — React doesn't re-run `useState` initializers on a prop change without a remount. Caught by manual browser verification (editing "Lunch" showed default "Break"/12:00–13:00 instead of the real values) before any automated test would have caught it, since the integration/unit tests exercise the service layer, not this component-mount subtlety. Fixed with `key={existing?.id ?? "new"}` on both sheet instances, forcing a clean remount per target. +- **A partial-day time off block was incorrectly rejected/accepted at a timezone-DST boundary in a new integration test.** The first version of a new "Block Time only blocks its own window" test constructed the appointment's `startAt` as a raw `${date}T12:00:00Z` UTC string; during British Summer Time that's 13:00 local London time, which landed exactly on the break's own end boundary and silently failed to trigger the expected conflict (a `<` vs `<=` edge, not a service bug — the fixture was wrong, not the code). Fixed by using the existing `wallClockToInstant` helper to construct the appointment from local business time instead of a raw UTC string, matching how the customer-booking integration suite already does this. +- **A new seed fixture collided with an existing Phase 4 E2E fixture.** The Phase 5 seed's "Leo has someone checked in" appointment originally used Alex Chen — the same customer, barber, and service (`Haircut & Style` with Leo Vance) the Phase 4 "book again" E2E journey already uses as its own fixture, so after that journey ran, `findAppointmentCard(page, haircut, "Leo Vance")` matched two cards instead of one. Fixed by moving the seed fixture to a different customer (Sam Osei) — same test coverage value (still exercises `selectCurrentAppointment`'s CHECKED_IN tier), no collision. +- **The new desktop "Right Now" panel duplicated a customer's name on the calendar page**, breaking a Playwright `getByText(...)` (strict-mode) assertion written before that panel existed. Not an app bug — the panel intentionally repeats the current appointment's summary beside the full day view — fixed by scoping the E2E assertion with `.first()`. + +## 9. Testing + +- **Unit** (Vitest, no DB — 121 tests total, up from 96 before this phase): `selectCurrentAppointment` (all 5 priority tiers, including "ignores CANCELLED/NO_SHOW even inside their old time window"), `staff.service.test.ts` (all pure validators — working hours, breaks, time off, including the partial-day-block edge cases — plus `assertOwnsResource`), `features/staff/authorization.test.ts` (own-business, own-appointment, own-note, including the "matching staffId but wrong business" case). +- **Integration** (Vitest, real throwaway Postgres — 55 tests total, up from 39 before this phase): `loyalty.service.integration.test.ts` (8 tests — idempotent auto-award, reward-created-once-not-stacked, disabled-programme rejection, staff/service eligibility rules, correction reason-required + balance-clamped) and `staff.service.integration.test.ts` (8 tests — working-hours round-trip, break/time-off conflict detection and the Save-Anyway bypass, partial-day-block-only-blocks-its-own-window, and the four permission-denied-not-thrown regression tests from §8). +- **End-to-end** (Playwright, `e2e/staff-journeys.spec.ts`, 9 journeys, run serially against the real dev server and seeded database): sign in + see today's schedule; open current appointment; book + check in + start (chains into journey 4); complete + auto/manual loyalty stamp; find a customer + add a note; book a follow-up from a completed visit; change working hours (round-trips and restores the seeded default); add a recurring break (adds then deletes itself); add time off that conflicts with a real seeded appointment, resolve via Save Anyway, and verify the appointment itself was never cancelled. Journeys 8 and 9 additionally clean up via a direct `afterAll` DB delete as a safety net, since a break/time-off they create has no deterministic id for the seed to upsert against on a future run — without it, a test failing partway through would leave Marcus permanently marked "off today" and break journey 1 on the next run (this was found and fixed live while building the suite). + +Running them: +```bash +npm run test # unit, no DB — 121 tests +npm run test:integration # needs DATABASE_URL → a real Postgres — 55 tests +npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres — 15 journeys (6 Phase 4 + 9 Phase 5) +``` + +## 10. Seed data added + +Extended `prisma/seed.ts` (still idempotent — safe to re-run against the same database): + +- Two more customers: Sam Osei (registered, sits exactly at the loyalty reward threshold) and Priya Kapoor (guest) — alongside the existing Alex Chen (registered) and Jordan Blake (guest). +- A second recurring break (Leo, Tue/Thu) alongside Marcus's existing daily lunch. +- Seven appointments spanning today across every status and all three staff members, anchored partly to fixed hours (COMPLETED, NO_SHOW, PENDING, CANCELLED, the later CONFIRMED "conflict fixture") and partly to the actual current time via a new `minutesFromNow()` helper (IN_PROGRESS, CHECKED_IN, and a not-checked-in-but-scheduled-now CONFIRMED row) — the latter three specifically so the seed reliably exercises all the top three tiers of `selectCurrentAppointment` across three different staff members regardless of when the seed happens to run. +- Loyalty accounts, a stamp transaction ledger, and one AVAILABLE reward (Sam Osei, at threshold). +- Two customer notes. + +**Known limitation, same as Phase 4's own fixed-hour fixtures:** the appointments anchored to fixed local hours (rather than `minutesFromNow`) assume the seed is run on the same calendar day it's being demonstrated/tested on — reasonable for a dev/demo seed re-run before each test suite invocation (as `e2e/global-setup.ts` does), not intended as a permanently-valid fixture set. + +## 11. Assumptions made + +- **A barber manages only their own schedule this phase.** No all-staff calendar, no acting-on-behalf-of-another-barber — explicitly out of scope, matches the brief. +- **Staff service eligibility is owner-only and unbuilt this phase** — a barber sees their assigned services read-only. +- **Loyalty configuration is consume-only** — eligible-staff/eligible-services lists, stamps-required, reward type/value all come from whatever's already seeded; no owner-facing config screen was built. +- **Notification preferences are UI-only** — no email/SMS/push delivery exists yet; this phase only persists the on/off grid. +- **A single-business deployment**, same as every prior phase — `CustomerProfile` still has no `businessId` column, so customer-directory scoping goes through the appointment relation (§5). + +## 12. What was manually verified + +- Signed in as each of the three seeded staff (owner-as-barber Marcus, Leo, Jamie) and walked the calendar, current appointment, clients list/detail, and every settings sub-page in a real browser against seeded data. +- The `assertOwnsResource` fix and the stale-form-state fix (§8) were both re-verified live in the browser after the code change, not just assumed fixed from reading the diff. +- The desktop sidebar, calendar+"Right Now" split, and both 2-column list/detail layouts at a 1280px viewport. +- `npm run build`, `npx tsc --noEmit`, and `npx eslint .` all clean on the final state. +- All three automated suites (121 unit / 55 integration / 15 E2E) passed together on one freshly created, migrated, and seeded database in a single run — the authoritative verification. (Re-running the full E2E suite repeatedly against the *same* long-lived database during development reliably reproduced stale-data collisions in both the pre-existing Phase 4 customer journeys and, before the §8 fix, the new Phase 5 ones — neither suite is designed to be re-run many times against one persistent database without a reseed; this is a pre-existing property of the Phase 4 suite, not something this phase changed, and is worth flagging for anyone setting up CI, per §13.) + +## 13. Before Phase 6 + +- Owner administration (business settings, staff management, service/category management, booking-rule management, loyalty programme configuration) is entirely untouched — every owner-only permission check in this phase exists but has nothing behind it yet beyond the pre-existing `/owner` placeholder. +- Real notification delivery (email/SMS/push) — this phase only built the on/off preference grid. +- The E2E/integration suites still assume a caller-provided disposable Postgres with no CI wiring to spin one up automatically, same open item Phase 4 already flagged. +- If a future phase adds an all-staff or owner calendar, `selectCurrentAppointment` and the calendar's day-grid rendering are already staff-id-parameterized and should extend cleanly rather than needing a rewrite. +- Loyalty reward *redemption* (marking an AVAILABLE reward as REDEEMED) was explicitly out of scope this phase and is still unbuilt — `currentStamps` is allowed to sit at or above the threshold indefinitely once a reward is available. diff --git a/e2e/helpers.ts b/e2e/helpers.ts index dbeddf1..6d78f7b 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -115,3 +115,42 @@ export async function completeRescheduleFlow(page: Page): Promise { await page.getByRole("button", { name: "Confirm Reschedule", exact: true }).click(); await expect(page).toHaveURL(/\/appointments\/[^/]+$/); } + +/** + * Drives the staff "book for customer" flow — same shared ServiceSelector + * and BarberDateTimePicker as the customer /book flow, mounted at the + * /staff/book/* routes instead (see src/app/staff/book). Assumes the page + * is already on /staff/book/services, i.e. a customer has just been + * selected (search result click or NewGuestCustomerForm submit both land + * there via a redirect). + */ +export async function completeStaffBookingFlow( + page: Page, + options: { serviceName: string; staffName?: string; notes?: string }, +): Promise { + const { serviceName, staffName, notes } = options; + + await expect(page).toHaveURL(/\/staff\/book\/services/); + await page.getByRole("button", { name: new RegExp(`^${serviceName}`) }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + + await expect(page).toHaveURL(/\/staff\/book\/barber-time/); + if (staffName) { + await section(page, "Barber").locator("select").selectOption({ label: staffName }); + await page.waitForLoadState("networkidle"); + } + await expect(section(page, "Date").locator("button[aria-pressed]").first()).toBeVisible(); + await section(page, "Date").locator("button[aria-pressed]:not([disabled])").first().click(); + const timeSection = section(page, "Time"); + await expect(timeSection.locator("button").first()).toBeVisible(); + await timeSection.locator("button").first().click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + + await expect(page).toHaveURL(/\/staff\/book\/review/); + if (notes) { + await page.getByLabel("Booking Notes (optional)").fill(notes); + } + await page.getByRole("button", { name: "Confirm Booking" }).click(); + + await expect(page).toHaveURL(/\/staff\/appointments\/[^/]+$/); +} diff --git a/e2e/staff-journeys.spec.ts b/e2e/staff-journeys.spec.ts new file mode 100644 index 0000000..0b9ea49 --- /dev/null +++ b/e2e/staff-journeys.spec.ts @@ -0,0 +1,230 @@ +import { expect, test } from "@playwright/test"; +import { Client } from "pg"; +import { SEEDED, completeStaffBookingFlow, signIn } from "./helpers"; + +/** + * The 9 barber-operations journeys required by the Phase 5 brief. Each + * drives real forms against the real dev server and the seeded Postgres + * database (see e2e/global-setup.ts / prisma/seed.ts). Journeys that only + * read data reuse the seed's fixed fixtures (today's appointments across + * every status, Sam Osei sitting at the loyalty threshold, Leo's time off); + * journeys that mutate state book a fresh appointment first so they don't + * fight each other or depend on run order — the same principle + * customer-journeys.spec.ts documents. The exceptions are the two tests + * that intentionally chain (3 → 4, both driving the *same* fresh + * appointment through check-in/start/complete/stamp) and the time-off + * conflict test, which deliberately reuses the seed's untouched + * "Marcus + Priya, later today" CONFIRMED appointment as its conflict + * fixture. + */ + +let chainedAppointmentUrl: string | null = null; + +test.describe.serial("barber operations journeys", () => { + // Journeys 8 and 9 create a break/time-off with no deterministic id for + // the seed to upsert against, so a mid-test assertion failure (a real + // possibility while iterating on this suite) could leave them behind and + // break journey 1 on the *next* run — journey 1 asserts today's schedule + // is visible, which a leftover "whole day off" row would hide. This + // safety net removes them directly regardless of how the tests above + // exited, on top of (not instead of) each test's own UI-driven cleanup. + test.afterAll(async () => { + const client = new Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + await client.query(`DELETE FROM "TimeOff" WHERE reason = 'E2E conflict test'`); + await client.query(`DELETE FROM "Break" WHERE label = 'Afternoon tea'`); + await client.end(); + }); + + test("1. barber signs in and sees today's schedule", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/staff"); + + await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); + // Seeded: Marcus has appointments in every status today. (.first() because on + // desktop the "Right Now" side panel repeats the in-progress customer's name.) + await expect(page.getByText("Alex Chen").first()).toBeVisible(); + await expect(page.getByText("Sam Osei").first()).toBeVisible(); + await expect(page.getByText("IN PROGRESS").first()).toBeVisible(); + }); + + test("2. barber opens the current appointment", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/staff/current"); + + // Tier 1 of selectCurrentAppointment: the IN_PROGRESS row wins. + await expect(page.getByText("Sam Osei")).toBeVisible(); + await expect(page.getByText("In Progress", { exact: true })).toBeVisible(); + await expect(page.getByText(/stamps/)).toBeVisible(); + }); + + test("3. barber books, checks in, and starts an appointment", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + + await page.goto("/staff/book?q=Jordan"); + await page.getByRole("button", { name: /Jordan Blake/ }).click(); + + await completeStaffBookingFlow(page, { + serviceName: SEEDED.services.haircut, + staffName: SEEDED.owner.name, + }); + chainedAppointmentUrl = page.url(); + + // A staff-created booking defaults to CONFIRMED (no PENDING approval step). + await expect(page.getByText("Confirmed", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Check In", exact: true }).click(); + await expect(page.getByText("Arrived", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "Start Appointment", exact: true }).click(); + await expect(page.getByText("In Progress", { exact: true })).toBeVisible(); + }); + + test("4. barber completes the appointment and adds a loyalty stamp", async ({ page }) => { + if (!chainedAppointmentUrl) throw new Error("journey 3 must run first to create the appointment"); + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto(chainedAppointmentUrl); + + await page.getByRole("button", { name: "Complete Appointment", exact: true }).click(); + await page.getByRole("button", { name: "Complete", exact: true }).click(); + await expect(page.getByText("This appointment is marked complete.")).toBeVisible(); + // Jordan Blake has no eligibility restrictions in the seed, so completion auto-awards a stamp. + await expect(page.getByText("A loyalty stamp was added for this visit.")).toBeVisible(); + await page.getByRole("button", { name: "Done", exact: true }).click(); + await expect(page.getByText("Completed", { exact: true })).toBeVisible(); + + // The manual "Add Loyalty Stamp" action is independent of the auto-award above. + await page.getByRole("button", { name: "Add Loyalty Stamp", exact: true }).click(); + await page.getByRole("button", { name: "Add Stamp", exact: true }).click(); + await expect(page.getByText("The stamp has been recorded on this customer's loyalty account.")).toBeVisible(); + await page.getByRole("button", { name: "Done", exact: true }).click(); + + // "Book Follow-Up" only appears once COMPLETED — hand off to journey 6. + await expect(page.getByRole("button", { name: "Book Follow-Up" })).toBeVisible(); + }); + + test("5. barber finds a customer and adds a note", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + + await page.goto("/staff/clients"); + await page.getByPlaceholder("Search by name, email, or phone").fill("Osei"); + await page.keyboard.press("Enter"); + await page.getByRole("link", { name: /Sam Osei/ }).click(); + + await expect(page.getByText("Sam Osei")).toBeVisible(); + const noteText = `E2E note ${Date.now()}`; + await page.getByPlaceholder("Add a note about this customer…").fill(noteText); + await page.getByRole("button", { name: "Add Note", exact: true }).click(); + await expect(page.getByText(noteText)).toBeVisible(); + }); + + test("6. barber books a follow-up from a completed appointment", async ({ page }) => { + if (!chainedAppointmentUrl) throw new Error("journey 4 must run first to complete the appointment"); + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto(chainedAppointmentUrl); + + await page.getByRole("button", { name: "Book Follow-Up" }).click(); + + // Services (and barber, if still active/bookable) are preselected from the original visit, + // but the barber still confirms this step themselves before moving on. + await expect(page).toHaveURL(/\/staff\/book\/services/); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + + await expect(page).toHaveURL(/\/staff\/book\/barber-time/); + await expect(page.locator("section", { hasText: "Date" }).locator("button[aria-pressed]").first()).toBeVisible(); + await page.locator("section", { hasText: "Date" }).locator("button[aria-pressed]:not([disabled])").first().click(); + await page.locator("section", { hasText: "Time" }).locator("button").first().click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + + await expect(page).toHaveURL(/\/staff\/book\/review/); + await page.getByRole("button", { name: "Confirm Booking" }).click(); + await expect(page).toHaveURL(/\/staff\/appointments\/[^/]+$/); + await expect(page.getByText("Jordan Blake")).toBeVisible(); + }); + + test("7. barber changes their working hours", async ({ page }) => { + await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); + await page.goto("/staff/settings/working-hours"); + + const sundaySwitch = page.getByRole("switch", { name: "Sunday working" }); + const wasOff = (await sundaySwitch.getAttribute("data-state")) === "unchecked"; + await sundaySwitch.click(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved.")).toBeVisible(); + + await page.reload(); + const expectedState = wasOff ? "checked" : "unchecked"; + await expect(page.getByRole("switch", { name: "Sunday working" })).toHaveAttribute( + "data-state", + expectedState, + ); + + // Restore the seeded default so this test is repeatable. + await page.getByRole("switch", { name: "Sunday working" }).click(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved.")).toBeVisible(); + }); + + test("8. barber adds a recurring break", async ({ page }) => { + await signIn(page, SEEDED.barberSkinfadeBeard.email, "password123"); + await page.goto("/staff/settings/breaks"); + + await page.getByRole("button", { name: "Add Break", exact: true }).click(); + const sheet = page.getByRole("dialog"); + await sheet.getByLabel("Label").fill("Afternoon tea"); + await sheet.getByLabel("Start").fill("15:30"); + await sheet.getByLabel("End").fill("15:45"); + await sheet.getByRole("button", { name: "Tue", exact: true }).click(); + await sheet.getByRole("button", { name: "Add Break", exact: true }).click(); + + await expect(page.getByText("Afternoon tea")).toBeVisible(); + await expect(page.getByText(/3:30 PM–3:45 PM/)).toBeVisible(); + + // Clean up — this test has no unique/deterministic id to upsert against on a re-run, + // so leaving it behind would accumulate a duplicate break every time the suite runs. + await page.getByText("Afternoon tea").click(); + await sheet.getByRole("button", { name: "Delete Break", exact: true }).click(); + await expect(page.getByText("Afternoon tea")).toHaveCount(0); + }); + + test("9. barber adds time off and resolves a scheduling conflict", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/staff/settings/time-off"); + + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + + await page.getByRole("button", { name: "Add Time Off", exact: true }).click(); + const sheet = page.getByRole("dialog"); + await sheet.getByLabel("Start date").fill(today); + await sheet.getByLabel("End date").fill(today); + await sheet.getByLabel("Reason (optional)").fill("E2E conflict test"); + await sheet.getByRole("button", { name: "Add Time Off", exact: true }).click(); + + // Seeded: Marcus has a CONFIRMED appointment with Priya Kapoor later today — a real conflict. + await expect(sheet.getByText(/overlaps \d+ existing appointment/)).toBeVisible(); + await expect(sheet.getByText("Priya Kapoor")).toBeVisible(); + + await sheet.getByRole("button", { name: "Save Anyway", exact: true }).click(); + await expect(page.getByText("E2E conflict test")).toBeVisible(); + + // The conflicting appointment itself is untouched — never silently cancelled. (The + // calendar's own day view now shows "You're off this day" instead of the appointment + // list, since Marcus just marked the whole day as time off — a separate, deliberate + // UI choice, not a sign the appointment was cancelled.) + await page.goto("/staff/clients"); + await page.getByPlaceholder("Search by name, email, or phone").fill("Priya"); + await page.keyboard.press("Enter"); + await page.getByRole("link", { name: /Priya Kapoor/ }).click(); + await expect(page.getByRole("tab", { name: "Upcoming" })).toHaveAttribute("data-state", "active"); + await expect(page.getByText(SEEDED.services.haircut).first()).toBeVisible(); + await expect(page.getByText("Confirmed", { exact: true }).first()).toBeVisible(); + + // Clean up — this test has no unique/deterministic id to upsert against on a re-run, + // so leaving Marcus marked off today would break journey 1's assertions next time. + await page.goto("/staff/settings/time-off"); + await page.getByText("E2E conflict test").click(); + const editSheet = page.getByRole("dialog"); + await editSheet.getByRole("button", { name: "Remove Time Off", exact: true }).click(); + await expect(page.getByText("E2E conflict test")).toHaveCount(0); + }); +}); diff --git a/prisma/migrations/20260802204707_staff_operations/migration.sql b/prisma/migrations/20260802204707_staff_operations/migration.sql new file mode 100644 index 0000000..dbee17a --- /dev/null +++ b/prisma/migrations/20260802204707_staff_operations/migration.sql @@ -0,0 +1,13 @@ +-- AlterTable +ALTER TABLE "Appointment" ADD COLUMN "createdByUserId" TEXT; + +-- AlterTable +ALTER TABLE "CustomerNote" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; + +-- AlterTable +ALTER TABLE "TimeOff" ADD COLUMN "endMinutes" INTEGER, +ADD COLUMN "startMinutes" INTEGER; + +-- AddForeignKey +ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c3b269..c179ccc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -294,6 +294,8 @@ model User { /// Whoever actually cancelled an appointment — the customer themselves, /// or the staff member who did it on their behalf. cancelledAppointments Appointment[] @relation("AppointmentCancelledBy") + /// Whoever initiated a booking — see Appointment.createdByUserId. + createdAppointments Appointment[] @relation("AppointmentCreatedBy") } model Account { @@ -561,6 +563,17 @@ model TimeOff { reason String? status TimeOffStatus @default(APPROVED) + /// Both null = blocks the whole day, every day in [startDate, endDate] + /// (the original, pre-Phase-5 behaviour — every existing row has both + /// null). When set, this row instead blocks only [startMinutes, + /// endMinutes] on each day in range, same as a break. This is also how + /// Phase 5's "Block Time" (a one-off slot like a meeting or personal + /// break) is represented — a TimeOff row with startDate = endDate and a + /// short reason — rather than inventing a separate blocked-time model; + /// see docs/phase-5-summary.md. + startMinutes Int? + endMinutes Int? + createdAt DateTime @default(now()) @@index([staffId, startDate, endDate]) @@ -599,6 +612,14 @@ model Appointment { cancelledByUserId String? cancelledByUser User? @relation("AppointmentCancelledBy", fields: [cancelledByUserId], references: [id], onDelete: SetNull) + /// Who initiated this booking — the customer themselves if self-service + /// (or null for a guest with no account), or the staff member if created + /// on the customer's behalf (Phase 5 "Book for customer"/"Follow-up"). + /// Purely provenance; never used for permission checks (ownership is via + /// customerId/staffId, not this field). + createdByUserId String? + createdByUser User? @relation("AppointmentCreatedBy", fields: [createdByUserId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -821,6 +842,11 @@ model CustomerNote { body String @db.Text createdAt DateTime @default(now()) + /// Set automatically whenever body changes — the only edit signal this + /// phase needs (see StaffService and docs/phase-5-summary.md for the + /// note permission model: a staff member may edit/delete only their own + /// note). A full field-level audit trail was judged unnecessary for v1. + updatedAt DateTime @updatedAt @@index([customerId]) } diff --git a/prisma/seed.ts b/prisma/seed.ts index f4d03f5..ae00ae9 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -19,6 +19,11 @@ function daysFromNow(days: number, hour: number, minute = 0): Date { return date; } +/** For appointments that need to be genuinely "in progress" / "starting soon" right now, regardless of what time the seed happens to run — used to exercise Phase 5's Current Appointment selection tiers reliably. */ +function minutesFromNow(minutes: number): Date { + return new Date(Date.now() + minutes * 60_000); +} + const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const prisma = new PrismaClient({ adapter }); @@ -262,6 +267,18 @@ async function main() { }, ], }, + // A short recurring break, distinct from Marcus's lunch — exercises + // the Breaks editor with a second, differently-timed staff member. + breaks: { + create: [ + { + label: "Coffee break", + startMinutes: 15 * 60, + endMinutes: 15 * 60 + 15, + days: { create: [{ dayOfWeek: Weekday.TUESDAY }, { dayOfWeek: Weekday.THURSDAY }] }, + }, + ], + }, }, }, }, @@ -342,7 +359,48 @@ async function main() { }, }); + // A second registered customer, close to their loyalty reward threshold — + // exercises the "available reward" indicator across the clients list, + // customer details, and calendar. + const secondCustomerPasswordHash = await bcrypt.hash("password123", 12); + const secondCustomerUser = await prisma.user.upsert({ + where: { email: "sam.osei@example.com" }, + update: {}, + create: { + email: "sam.osei@example.com", + name: "Sam Osei", + passwordHash: secondCustomerPasswordHash, + emailVerified: new Date(), + customerProfile: { + create: { + firstName: "Sam", + lastName: "Osei", + email: "sam.osei@example.com", + phone: "+44 7700 900789", + isGuest: false, + preferredBarberId: barberUser.staffProfile?.id, + }, + }, + }, + include: { customerProfile: true }, + }); + + // A second guest customer, for clients-list search/sort variety. + const secondGuestCustomer = await prisma.customerProfile.upsert({ + where: { id: `${business.id}-guest-priya-kapoor` }, + update: {}, + create: { + id: `${business.id}-guest-priya-kapoor`, + firstName: "Priya", + lastName: "Kapoor", + email: "priya.kapoor@example.com", + phone: "+44 7700 900321", + isGuest: true, + }, + }); + const registeredCustomer = registeredCustomerUser.customerProfile; + const secondCustomer = secondCustomerUser.customerProfile; if (registeredCustomer && ownerUser.staffProfile && barberUser.staffProfile) { // Upcoming, confirmed — registered customer with the owner/barber. const upcomingStart = daysFromNow(3, 11, 0); @@ -410,12 +468,265 @@ async function main() { }); } + // ── Phase 5: today's appointments across statuses ───────────────────── + // Spread across all three staff so signing in as any of them shows a + // populated Calendar/Current Appointment immediately. The IN_PROGRESS, + // CHECKED_IN, and "scheduled now" (CONFIRMED) rows below are anchored to + // the actual current time (minutesFromNow) rather than a fixed hour, so + // each one reliably exercises a different tier of + // AppointmentService.selectCurrentAppointment regardless of when the + // seed is run. + if (ownerUser.staffProfile && barberUser.staffProfile && secondBarberUser.staffProfile && secondCustomer) { + const marcus = ownerUser.staffProfile; + const leo = barberUser.staffProfile; + const jamie = secondBarberUser.staffProfile; + + // Marcus: completed earlier today (tests loyalty stamp-on-completion). + const marcusCompletedStart = daysFromNow(0, 9, 0); + await prisma.appointment.upsert({ + where: { id: `${business.id}-appt-today-completed` }, + update: {}, + create: { + id: `${business.id}-appt-today-completed`, + businessId: business.id, + customerId: registeredCustomer!.id, + staffId: marcus.id, + status: "COMPLETED", + startAt: marcusCompletedStart, + endAt: new Date(marcusCompletedStart.getTime() + haircut.durationMinutes * 60_000), + services: { + create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], + }, + }, + }); + + // Marcus: no-show earlier today. + const marcusNoShowStart = daysFromNow(0, 9, 45); + await prisma.appointment.upsert({ + where: { id: `${business.id}-appt-today-noshow` }, + update: {}, + create: { + id: `${business.id}-appt-today-noshow`, + businessId: business.id, + customerId: guestCustomer.id, + staffId: marcus.id, + status: "NO_SHOW", + startAt: marcusNoShowStart, + endAt: new Date(marcusNoShowStart.getTime() + skinfade.durationMinutes * 60_000), + services: { + create: [{ serviceId: skinfade.id, priceAtBooking: skinfade.price, durationAtBookingMinutes: skinfade.durationMinutes }], + }, + }, + }); + + // Marcus: IN_PROGRESS right now — Current Appointment tier 1. + await prisma.appointment.upsert({ + where: { id: `${business.id}-appt-today-inprogress` }, + update: {}, + create: { + id: `${business.id}-appt-today-inprogress`, + businessId: business.id, + customerId: secondCustomer.id, + staffId: marcus.id, + status: "IN_PROGRESS", + startAt: minutesFromNow(-10), + endAt: minutesFromNow(20), + notes: "Regular, wants it short on the sides.", + services: { + create: [{ serviceId: skinfade.id, priceAtBooking: skinfade.price, durationAtBookingMinutes: skinfade.durationMinutes }], + }, + }, + }); + + // Marcus: confirmed later this evening — also a natural fixture for + // testing the break/time-off "overlaps an appointment" conflict flow + // (try adding a break or blocking time from 16:30-17:30 today). + const marcusLaterStart = daysFromNow(0, 17, 0); + await prisma.appointment.upsert({ + where: { id: `${business.id}-appt-today-confirmed` }, + update: {}, + create: { + id: `${business.id}-appt-today-confirmed`, + businessId: business.id, + customerId: secondGuestCustomer.id, + staffId: marcus.id, + status: "CONFIRMED", + startAt: marcusLaterStart, + endAt: new Date(marcusLaterStart.getTime() + haircut.durationMinutes * 60_000), + services: { + create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], + }, + }, + }); + + // Leo: checked in, about to start — Current Appointment tier 2. + await prisma.appointment.upsert({ + where: { id: `${business.id}-appt-today-checkedin` }, + update: {}, + create: { + id: `${business.id}-appt-today-checkedin`, + businessId: business.id, + // Deliberately not Alex Chen — she's the customer e2e suite's own + // "book again" fixture with Leo (see appt-completed above and + // e2e/customer-journeys.spec.ts), and doubling up on that exact + // customer/staff/service combo here would make her appointments + // list match two cards instead of one. + customerId: secondCustomer.id, + staffId: leo.id, + status: "CHECKED_IN", + startAt: minutesFromNow(5), + endAt: minutesFromNow(5 + haircut.durationMinutes), + services: { + create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], + }, + }, + }); + + // Leo: pending confirmation later today. + const leoPendingStart = daysFromNow(0, 18, 0); + await prisma.appointment.upsert({ + where: { id: `${business.id}-appt-today-pending` }, + update: {}, + create: { + id: `${business.id}-appt-today-pending`, + businessId: business.id, + customerId: secondCustomer.id, + staffId: leo.id, + status: "PENDING", + startAt: leoPendingStart, + endAt: new Date(leoPendingStart.getTime() + haircut.durationMinutes * 60_000), + services: { + create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], + }, + }, + }); + + // Jamie: confirmed and already underway by the clock, but never + // checked in — Current Appointment tier 3 ("scheduled now"). + await prisma.appointment.upsert({ + where: { id: `${business.id}-appt-today-scheduled-now` }, + update: {}, + create: { + id: `${business.id}-appt-today-scheduled-now`, + businessId: business.id, + customerId: secondGuestCustomer.id, + staffId: jamie.id, + status: "CONFIRMED", + startAt: minutesFromNow(-5), + endAt: minutesFromNow(25), + services: { + create: [{ serviceId: skinfade.id, priceAtBooking: skinfade.price, durationAtBookingMinutes: skinfade.durationMinutes }], + }, + }, + }); + + // Jamie: cancelled earlier today, for status-variety in the calendar/agenda view. + const jamieCancelledStart = daysFromNow(0, 14, 0); + await prisma.appointment.upsert({ + where: { id: `${business.id}-appt-today-cancelled` }, + update: {}, + create: { + id: `${business.id}-appt-today-cancelled`, + businessId: business.id, + customerId: guestCustomer.id, + staffId: jamie.id, + status: "CANCELLED", + startAt: jamieCancelledStart, + endAt: new Date(jamieCancelledStart.getTime() + beardTrim.durationMinutes * 60_000), + cancellationReason: "Customer rescheduled by phone", + cancelledAt: minutesFromNow(-90), + services: { + create: [{ serviceId: beardTrim.id, priceAtBooking: beardTrim.price, durationAtBookingMinutes: beardTrim.durationMinutes }], + }, + }, + }); + + // ── Loyalty: accounts, a stamp ledger, and one available reward ───── + const loyaltyProgramme = await prisma.loyaltyProgramme.findUniqueOrThrow({ + where: { businessId: business.id }, + }); + + if (registeredCustomer) { + const alexAccount = await prisma.loyaltyAccount.upsert({ + where: { customerId_loyaltyProgrammeId: { customerId: registeredCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id } }, + update: {}, + create: { customerId: registeredCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id, currentStamps: 3 }, + }); + const alexStampCount = await prisma.loyaltyTransaction.count({ where: { loyaltyAccountId: alexAccount.id } }); + if (alexStampCount === 0) { + await prisma.loyaltyTransaction.createMany({ + data: [ + { loyaltyAccountId: alexAccount.id, type: "STAMP_ADDED", amount: 1, authoredByStaffId: leo.id, appointmentId: `${business.id}-appt-completed` }, + { loyaltyAccountId: alexAccount.id, type: "STAMP_ADDED", amount: 1, authoredByStaffId: marcus.id }, + { loyaltyAccountId: alexAccount.id, type: "STAMP_ADDED", amount: 1, authoredByStaffId: marcus.id }, + ], + }); + } + } + + // Sam: right at the reward threshold — exercises the "available reward" indicator end-to-end. + const samAccount = await prisma.loyaltyAccount.upsert({ + where: { customerId_loyaltyProgrammeId: { customerId: secondCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id } }, + update: {}, + create: { customerId: secondCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id, currentStamps: loyaltyProgramme.stampsRequired }, + }); + const samStampCount = await prisma.loyaltyTransaction.count({ where: { loyaltyAccountId: samAccount.id } }); + if (samStampCount === 0) { + await prisma.loyaltyTransaction.createMany({ + data: Array.from({ length: loyaltyProgramme.stampsRequired }, () => ({ + loyaltyAccountId: samAccount.id, + type: "STAMP_ADDED" as const, + amount: 1, + authoredByStaffId: leo.id, + })), + }); + await prisma.loyaltyReward.create({ + data: { + loyaltyAccountId: samAccount.id, + rewardType: loyaltyProgramme.rewardType, + value: loyaltyProgramme.rewardValue, + label: loyaltyProgramme.rewardLabel ?? "Reward", + status: "AVAILABLE", + }, + }); + } + + // ── Customer notes — internal-only, never shown on customer routes ── + if (registeredCustomer) { + await prisma.customerNote.upsert({ + where: { id: `${business.id}-note-alex-1` }, + update: {}, + create: { + id: `${business.id}-note-alex-1`, + customerId: registeredCustomer.id, + authorStaffId: marcus.id, + body: "Prefers scissor-over-comb, not clippers, on top. Sensitive skin around the neckline.", + }, + }); + } + await prisma.customerNote.upsert({ + where: { id: `${business.id}-note-sam-1` }, + update: {}, + create: { + id: `${business.id}-note-sam-1`, + customerId: secondCustomer.id, + authorStaffId: leo.id, + body: "Always running a few minutes late — worth a courtesy reminder text.", + }, + }); + } + console.log("Seed complete:", { business: business.name, roles: [ownerRole.name, barberRole.name], services: [haircut.name, skinfade.name, beardTrim.name], staff: [ownerUser.name, barberUser.name, secondBarberUser.name], - customers: [registeredCustomerUser.name, `${guestCustomer.firstName} ${guestCustomer.lastName} (guest)`], + customers: [ + registeredCustomerUser.name, + secondCustomerUser.name, + `${guestCustomer.firstName} ${guestCustomer.lastName} (guest)`, + `${secondGuestCustomer.firstName} ${secondGuestCustomer.lastName} (guest)`, + ], }); } diff --git a/src/app/(customer)/appointments/page.tsx b/src/app/(customer)/appointments/page.tsx index a5ebeda..a6bcd3d 100644 --- a/src/app/(customer)/appointments/page.tsx +++ b/src/app/(customer)/appointments/page.tsx @@ -112,7 +112,7 @@ export default async function AppointmentsPage() { key={appointment.id} status={STATUS_MAP[appointment.status] ?? "confirmed"} serviceName={appointment.services.map((s) => s.service.name).join(" + ")} - staff={{ id: appointment.staffId, name: appointment.staff.user.name ?? "Barber" }} + person={{ id: appointment.staffId, name: appointment.staff.user.name ?? "Barber" }} formattedDate={appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", diff --git a/src/app/(customer)/home/page.tsx b/src/app/(customer)/home/page.tsx index 559865c..6ec187c 100644 --- a/src/app/(customer)/home/page.tsx +++ b/src/app/(customer)/home/page.tsx @@ -163,7 +163,7 @@ export default async function CustomerHomePage() { s.service.name).join(" + ")} - staff={{ + person={{ id: upcomingAppointment.staffId, name: upcomingAppointment.staff.user.name ?? "Barber", }} diff --git a/src/app/(owner)/layout.tsx b/src/app/(owner)/layout.tsx index fc77e1c..553b89d 100644 --- a/src/app/(owner)/layout.tsx +++ b/src/app/(owner)/layout.tsx @@ -1,7 +1,6 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; -import { BottomNav } from "@/components/navigation/bottom-nav"; -import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items"; +import { StaffShell } from "@/components/navigation/staff-shell"; import { hasPermission } from "@/lib/auth/permissions"; import { Permission } from "@/generated/prisma/enums"; @@ -21,10 +20,5 @@ export default async function OwnerLayout({ children }: { children: React.ReactN redirect("/staff"); } - return ( -
- {children} - -
- ); + return {children}; } diff --git a/src/app/(staff)/layout.tsx b/src/app/(staff)/layout.tsx index 829b3c9..a03919c 100644 --- a/src/app/(staff)/layout.tsx +++ b/src/app/(staff)/layout.tsx @@ -1,7 +1,6 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; -import { BottomNav } from "@/components/navigation/bottom-nav"; -import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items"; +import { StaffShell } from "@/components/navigation/staff-shell"; import { prisma } from "@/lib/db/prisma"; export default async function StaffLayout({ children }: { children: React.ReactNode }) { @@ -19,10 +18,5 @@ export default async function StaffLayout({ children }: { children: React.ReactN redirect("/home"); } - return ( -
- {children} - -
- ); + return {children}; } diff --git a/src/app/(staff)/staff/appointments/[id]/page.tsx b/src/app/(staff)/staff/appointments/[id]/page.tsx new file mode 100644 index 0000000..9d3fa3b --- /dev/null +++ b/src/app/(staff)/staff/appointments/[id]/page.tsx @@ -0,0 +1,266 @@ +import type { Metadata } from "next"; +import { CalendarPlus, Mail, Phone } from "lucide-react"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { Card } from "@/components/ui/card"; +import { StatusPill } from "@/components/ui/status-pill"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { getLoyaltySummary } from "@/services/loyalty/loyalty.service"; +import { getAppointmentById } from "@/services/scheduling/appointment.service"; +import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization"; +import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions"; +import { AddStampButton } from "@/features/staff/components/add-stamp-button"; +import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button"; +import type { AppointmentStatus } from "@/types/domain"; + +export const metadata: Metadata = { title: "Appointment Details" }; +export const dynamic = "force-dynamic"; + +const STATUS_MAP: Record = { + PENDING: "pending_approval", + CONFIRMED: "confirmed", + CHECKED_IN: "arrived", + IN_PROGRESS: "in_progress", + COMPLETED: "completed", + CANCELLED: "cancelled", + NO_SHOW: "no_show", +}; +const RESCHEDULABLE = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]); + +export default async function StaffAppointmentDetailsPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const { id } = await params; + const appointment = await getAppointmentById(prisma, id); + if (!appointment) notFound(); + + try { + assertOwnAppointment(appointment, staff); + } catch (error) { + if (error instanceof StaffPermissionDeniedError) notFound(); + throw error; + } + + const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([ + prisma.customerNote.findMany({ + where: { customerId: appointment.customerId }, + orderBy: { createdAt: "desc" }, + take: 5, + include: { author: { include: { user: { select: { name: true } } } } }, + }), + prisma.loyaltyTransaction.findMany({ + where: { appointmentId: appointment.id }, + orderBy: { createdAt: "desc" }, + }), + getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: staff.businessId }), + appointment.cancelledByUserId + ? prisma.user.findUnique({ + where: { id: appointment.cancelledByUserId }, + select: { name: true }, + }) + : null, + ]); + + const tz = staff.business.timezone; + const totalDuration = appointment.services.reduce( + (sum, s) => sum + s.durationAtBookingMinutes, + 0, + ); + const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0); + + return ( +
+ + +
+ +
+ + + Ref: {appointment.id.slice(-8)} + +
+ +
+ + {appointment.customer.firstName} {appointment.customer.lastName} + +

+ {appointment.customer.isGuest ? "Guest customer" : "Registered customer"} +

+
+ +
+ {appointment.customer.phone && ( + + + )} + + +
+ +
+
+ + Services + +

+ {appointment.services.map((s) => s.service.name).join(" + ")} +

+
+
+ + Date + +

+ {appointment.startAt.toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: tz, + })} +

+
+
+ + Time + +

+ {appointment.startAt.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: tz, + })}{" "} + –{" "} + {appointment.endAt.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: tz, + })} +

+
+
+ + Duration / Price + +

+ {totalDuration} min · £{totalPrice.toFixed(2)} +

+
+
+ + {appointment.notes && ( +
+ + Booking Notes + +

{appointment.notes}

+
+ )} + + {notes.length > 0 && ( +
+ + Customer Notes + +
+ {notes.map((n) => ( +

+ {n.author.user.name ?? "Staff"}: {n.body} +

+ ))} +
+
+ )} + + {loyalty?.isEnabled && ( +
+ + Loyalty + +

+ {loyalty.currentStamps}/{loyalty.stampsRequired} stamps + {loyalty.availableReward && ( + · Reward available + )} +

+ {loyaltyTransactions.length > 0 && ( +
    + {loyaltyTransactions.map((t) => ( +
  • + {t.amount > 0 ? "+" : ""} + {t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} —{" "} + {t.type.replace("_", " ").toLowerCase()} + {t.reason ? ` (${t.reason})` : ""} +
  • + ))} +
+ )} +
+ )} + + {appointment.status === "CANCELLED" && ( +
+ + Cancellation + +

+ Cancelled {appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: tz })} + {cancelledByUser ? ` by ${cancelledByUser.name}` : ""} +

+ {appointment.cancellationReason && ( +

+ “{appointment.cancellationReason}” +

+ )} +
+ )} +
+ +
+ + + {RESCHEDULABLE.has(appointment.status) && ( + + Reschedule + + )} + {appointment.status === "COMPLETED" && ( + + )} +
+ + + + {loyalty?.isEnabled && ( + + )} +
+
+ ); +} diff --git a/src/app/(staff)/staff/clients/[id]/page.tsx b/src/app/(staff)/staff/clients/[id]/page.tsx new file mode 100644 index 0000000..19cd770 --- /dev/null +++ b/src/app/(staff)/staff/clients/[id]/page.tsx @@ -0,0 +1,271 @@ +import type { Metadata } from "next"; +import Image from "next/image"; +import Link from "next/link"; +import { CalendarX2, Mail, Phone } from "lucide-react"; +import { notFound, redirect } from "next/navigation"; +import { AppointmentCard } from "@/components/ui/appointment-card"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { EmptyState } from "@/components/feedback/empty-state"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service"; +import { listAppointments } from "@/services/scheduling/appointment.service"; +import { AddStampButton } from "@/features/staff/components/add-stamp-button"; +import { CorrectStampButton } from "@/features/staff/components/correct-stamp-button"; +import { CustomerNotes } from "@/features/staff/components/customer-notes"; +import type { CustomerNoteItem } from "@/features/staff/components/customer-notes"; +import type { AppointmentStatus } from "@/types/domain"; + +export const metadata: Metadata = { title: "Client Details" }; +export const dynamic = "force-dynamic"; + +const STATUS_MAP: Record = { + PENDING: "pending_approval", + CONFIRMED: "confirmed", + CHECKED_IN: "arrived", + IN_PROGRESS: "in_progress", + COMPLETED: "completed", + CANCELLED: "cancelled", + NO_SHOW: "no_show", +}; + +export default async function StaffCustomerDetailsPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const { id } = await params; + const customer = await prisma.customerProfile.findUnique({ + where: { id }, + include: { + preferredBarber: { include: { user: { select: { name: true } } } }, + user: { select: { image: true } }, + }, + }); + if (!customer) notFound(); + + // "Belongs to this business" is via the appointment relation — see + // docs/phase-5-summary.md, same reasoning as the clients list query. + const belongsToBusiness = await prisma.appointment.findFirst({ + where: { customerId: id, businessId: staff.businessId }, + select: { id: true }, + }); + if (!belongsToBusiness) notFound(); + + const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([ + listAppointments(prisma, { + customerId: id, + businessId: staff.businessId, + statuses: ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"], + }), + listAppointments(prisma, { + customerId: id, + businessId: staff.businessId, + statuses: ["COMPLETED"], + }), + listAppointments(prisma, { + customerId: id, + businessId: staff.businessId, + statuses: ["CANCELLED", "NO_SHOW"], + }), + prisma.customerNote.findMany({ + where: { customerId: id }, + orderBy: { createdAt: "desc" }, + include: { author: { include: { user: { select: { name: true } } } } }, + }), + getLoyaltySummary(prisma, { customerId: id, businessId: staff.businessId }), + ]); + + const loyaltyTransactions = loyalty?.accountId + ? await getLoyaltyHistory(prisma, { loyaltyAccountId: loyalty.accountId, limit: 10 }) + : []; + + const notes: CustomerNoteItem[] = notesRaw.map((n) => ({ + id: n.id, + body: n.body, + createdAt: n.createdAt.toISOString(), + updatedAt: n.updatedAt.toISOString(), + authorStaffId: n.authorStaffId, + authorName: n.author.user.name ?? "Staff", + })); + + const tz = staff.business.timezone; + + const renderList = (appointments: typeof upcoming, emptyLabel: string) => { + if (appointments.length === 0) { + return ; + } + return ( +
+ {appointments.map((a) => ( + s.service.name).join(" + ")} + person={{ id: a.staffId, name: a.staff.user.name ?? "Barber" }} + formattedDate={a.startAt.toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: tz, + })} + formattedTime={a.startAt.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: tz, + })} + actions={ + + View + + } + /> + ))} +
+ ); + }; + + return ( +
+ + +
+ +
+ {customer.user?.image ? ( + + ) : ( +
+ {customer.firstName.charAt(0)} + {customer.lastName.charAt(0)} +
+ )} +
+

+ {customer.firstName} {customer.lastName} +

+

+ {customer.isGuest ? "Guest customer" : "Registered customer"} +

+
+
+ +
+

{customer.email}

+ {customer.phone &&

{customer.phone}

} + {customer.preferredBarber && ( +

+ Preferred barber: {customer.preferredBarber.user.name ?? "—"} +

+ )} +
+ +
+ {customer.phone && ( + + + )} + + + +
+ + {loyalty?.isEnabled && loyalty.accountId && ( +
+ + Loyalty + +

+ {loyalty.currentStamps}/{loyalty.stampsRequired} stamps + {loyalty.availableReward && ( + · Reward available + )} +

+
+ + +
+ {loyaltyTransactions.length > 0 && ( +
    + {loyaltyTransactions.map((t) => ( +
  • + {t.createdAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}{" "} + — {t.amount > 0 ? "+" : ""} + {t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} + {t.reason ? ` (${t.reason})` : ""} + {t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""} +
  • + ))} +
+ )} +
+ )} + +
+
+

{past.length}

+

Visits

+
+
+

{upcoming.length}

+

Upcoming

+
+
+

{cancelled.length}

+

+ Cancelled +

+
+
+
+ +
+ + + Upcoming + Past + Cancelled + + + {renderList(upcoming, "No upcoming appointments")} + + {renderList(past, "No past appointments")} + + {renderList(cancelled, "No cancelled appointments")} + + + + +

Notes

+ +
+
+
+
+ ); +} diff --git a/src/app/(staff)/staff/clients/page.tsx b/src/app/(staff)/staff/clients/page.tsx index 412c407..dd5aafd 100644 --- a/src/app/(staff)/staff/clients/page.tsx +++ b/src/app/(staff)/staff/clients/page.tsx @@ -1,9 +1,158 @@ import type { Metadata } from "next"; -import { Users } from "lucide-react"; -import { ComingSoon } from "@/components/feedback/coming-soon"; +import { Search, Users } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { EmptyState } from "@/components/feedback/empty-state"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { matchesCustomerSearch } from "@/services/customer/customer.service"; +import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service"; +import { cn } from "@/lib/utils/cn"; export const metadata: Metadata = { title: "Clients" }; +export const dynamic = "force-dynamic"; -export default function StaffClientsPage() { - return ; +const UPCOMING_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN"]; + +export default async function StaffClientsPage({ + searchParams, +}: { + searchParams: Promise<{ q?: string; sort?: string }>; +}) { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const { q = "", sort } = await searchParams; + + // Scoped via the appointment relation, not "every CustomerProfile row" — + // a real business boundary even though this is a single-business v1 app + // (see docs/phase-5-summary.md). + const customers = await prisma.customerProfile.findMany({ + where: { appointments: { some: { businessId: staff.businessId } } }, + include: { + appointments: { where: { businessId: staff.businessId }, orderBy: { startAt: "desc" } }, + }, + }); + + const loyaltySummaries = await getLoyaltySummariesForCustomers(prisma, { + businessId: staff.businessId, + customerIds: customers.map((c) => c.id), + }); + + const filtered = customers.filter((c) => matchesCustomerSearch(c, q)); + + const sorted = [...filtered].sort((a, b) => { + if (sort === "recent") { + const aLatest = a.appointments[0]?.startAt.getTime() ?? 0; + const bLatest = b.appointments[0]?.startAt.getTime() ?? 0; + return bLatest - aLatest; + } + return `${a.lastName} ${a.firstName}`.localeCompare(`${b.lastName} ${b.firstName}`); + }); + + const now = new Date(); + + return ( +
+

Clients

+ +
+ {sort && } +
+ ); } diff --git a/src/app/(staff)/staff/current/page.tsx b/src/app/(staff)/staff/current/page.tsx index 168bc5d..b7584cc 100644 --- a/src/app/(staff)/staff/current/page.tsx +++ b/src/app/(staff)/staff/current/page.tsx @@ -1,9 +1,226 @@ import type { Metadata } from "next"; -import { Scissors } from "lucide-react"; -import { ComingSoon } from "@/components/feedback/coming-soon"; +import { Mail, NotebookText, Phone, Scissors } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { Card } from "@/components/ui/card"; +import { StatusPill } from "@/components/ui/status-pill"; +import { EmptyState } from "@/components/feedback/empty-state"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { todayIn } from "@/lib/time/business-time"; +import { getLoyaltySummary } from "@/services/loyalty/loyalty.service"; +import { + listAppointments, + selectCurrentAppointment, +} from "@/services/scheduling/appointment.service"; +import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions"; +import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button"; +import { AddStampButton } from "@/features/staff/components/add-stamp-button"; +import type { AppointmentStatus } from "@/types/domain"; export const metadata: Metadata = { title: "Current Appointment" }; +export const dynamic = "force-dynamic"; -export default function StaffCurrentPage() { - return ; +const STATUS_MAP: Record = { + PENDING: "pending_approval", + CONFIRMED: "confirmed", + CHECKED_IN: "arrived", + IN_PROGRESS: "in_progress", + COMPLETED: "completed", + CANCELLED: "cancelled", + NO_SHOW: "no_show", +}; + +export default async function StaffCurrentPage() { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const today = todayIn(staff.business.timezone); + const todaysAppointments = await listAppointments(prisma, { + businessId: staff.businessId, + staffId: staff.id, + fromDate: today, + toDate: today, + businessTimezone: staff.business.timezone, + }); + + const current = selectCurrentAppointment(todaysAppointments, new Date()); + + if (!current) { + return ( +
+

Current Appointment

+ +
+ ); + } + + const [notes, lastCompleted, loyalty] = await Promise.all([ + prisma.customerNote.findMany({ + where: { customerId: current.customerId }, + orderBy: { createdAt: "desc" }, + take: 2, + include: { author: { include: { user: { select: { name: true } } } } }, + }), + prisma.appointment.findFirst({ + where: { customerId: current.customerId, status: "COMPLETED", id: { not: current.id } }, + orderBy: { startAt: "desc" }, + include: { services: { include: { service: true } } }, + }), + getLoyaltySummary(prisma, { customerId: current.customerId, businessId: staff.businessId }), + ]); + + const totalDuration = current.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0); + const totalPrice = current.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0); + const tz = staff.business.timezone; + + return ( +
+

Current Appointment

+ + +
+
+

+ {current.customer.firstName} {current.customer.lastName} +

+

+ {current.customer.isGuest ? "Guest customer" : "Registered customer"} +

+
+ +
+ +
+ {current.customer.phone && ( + + + )} + + + + View Customer + +
+ +
+
+ + Services + +

{current.services.map((s) => s.service.name).join(" + ")}

+
+
+ + Time + +

+ {current.startAt.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: tz, + })}{" "} + –{" "} + {current.endAt.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: tz, + })} +

+
+
+ + Duration + +

{totalDuration} min

+
+
+ + Price + +

£{totalPrice.toFixed(2)}

+
+
+ + {current.notes && ( +
+ + Booking Notes + +

{current.notes}

+
+ )} + + {notes.length > 0 && ( +
+ + +
+ {notes.map((n) => ( +

+ {n.author.user.name ?? "Staff"}: {n.body} +

+ ))} +
+
+ )} + + {loyalty?.isEnabled && ( +
+ + Loyalty + +

+ {loyalty.currentStamps}/{loyalty.stampsRequired} stamps + {loyalty.availableReward && ( + · Reward available + )} +

+
+ )} + + {lastCompleted && ( +
+ + Last Visit + +

+ {lastCompleted.services.map((s) => s.service.name).join(" + ")} ·{" "} + {lastCompleted.startAt.toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + timeZone: tz, + })} +

+
+ )} +
+ + + +
+ {loyalty?.isEnabled && ( + + )} + +
+
+ ); } diff --git a/src/app/(staff)/staff/page.tsx b/src/app/(staff)/staff/page.tsx index 51f05a8..e85972d 100644 --- a/src/app/(staff)/staff/page.tsx +++ b/src/app/(staff)/staff/page.tsx @@ -1,9 +1,276 @@ import type { Metadata } from "next"; -import { CalendarDays } from "lucide-react"; -import { ComingSoon } from "@/components/feedback/coming-soon"; +import { CalendarX2, Plus } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/feedback/empty-state"; +import { AppointmentCard } from "@/components/ui/appointment-card"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { + calendarDateToUtcMidnight, + minutesFromMidnight, + todayIn, + weekdayOf, +} from "@/lib/time/business-time"; +import type { CalendarDate } from "@/lib/time/business-time"; +import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service"; +import { listAppointments, selectCurrentAppointment } from "@/services/scheduling/appointment.service"; +import { DateStrip } from "@/features/staff/components/date-strip"; +import { DayCalendar } from "@/features/staff/components/day-calendar"; +import type { CalendarAppointment, CalendarBlock } from "@/features/staff/components/day-calendar"; +import { BlockTimeButton } from "@/features/staff/components/block-time-button"; +import { cn } from "@/lib/utils/cn"; +import type { AppointmentStatus } from "@/types/domain"; export const metadata: Metadata = { title: "Calendar" }; +export const dynamic = "force-dynamic"; -export default function StaffCalendarPage() { - return ; +const STATUS_MAP: Record = { + PENDING: "pending_approval", + CONFIRMED: "confirmed", + CHECKED_IN: "arrived", + IN_PROGRESS: "in_progress", + COMPLETED: "completed", + CANCELLED: "cancelled", + NO_SHOW: "no_show", +}; + +export default async function StaffCalendarPage({ + searchParams, +}: { + searchParams: Promise<{ date?: string; view?: string }>; +}) { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const { date: dateParam, view: viewParam } = await searchParams; + const today = todayIn(staff.business.timezone); + const date: CalendarDate = dateParam ?? today; + const view = viewParam === "agenda" ? "agenda" : "day"; + const weekday = weekdayOf(date); + const dateUtcMidnight = calendarDateToUtcMidnight(date); + + const [workingHours, breakDays, timeOffRows, appointments] = await Promise.all([ + prisma.workingHours.findUnique({ + where: { staffId_dayOfWeek: { staffId: staff.id, dayOfWeek: weekday } }, + }), + prisma.breakDay.findMany({ + where: { dayOfWeek: weekday, break: { staffId: staff.id } }, + include: { break: true }, + }), + prisma.timeOff.findMany({ + where: { + staffId: staff.id, + status: "APPROVED", + startDate: { lte: dateUtcMidnight }, + endDate: { gte: dateUtcMidnight }, + }, + }), + listAppointments(prisma, { + businessId: staff.businessId, + staffId: staff.id, + fromDate: date, + toDate: date, + businessTimezone: staff.business.timezone, + }), + ]); + + const wholeDayOff = timeOffRows.find((t) => t.startMinutes === null); + + const rewardFlags = await getAvailableRewardFlags(prisma, { + businessId: staff.businessId, + customerIds: [...new Set(appointments.map((a) => a.customerId))], + }); + + const workingWindow = + workingHours && + !workingHours.isOff && + workingHours.startMinutes !== null && + workingHours.endMinutes !== null + ? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes } + : null; + + const blocks: CalendarBlock[] = [ + ...breakDays.map((bd) => ({ + id: `break-${bd.id}`, + startMinutes: bd.break.startMinutes, + endMinutes: bd.break.endMinutes, + label: bd.break.label, + kind: "break" as const, + })), + ...timeOffRows + .filter((t) => t.startMinutes !== null && t.endMinutes !== null) + .map((t) => ({ + id: `timeoff-${t.id}`, + startMinutes: t.startMinutes!, + endMinutes: t.endMinutes!, + label: t.reason ?? "Blocked", + kind: "timeOff" as const, + })), + ]; + + const calendarAppointments: CalendarAppointment[] = appointments.map((a) => ({ + id: a.id, + startMinutes: minutesFromMidnight(a.startAt, staff.business.timezone), + endMinutes: minutesFromMidnight(a.endAt, staff.business.timezone), + customerName: `${a.customer.firstName} ${a.customer.lastName}`, + serviceNames: a.services.map((s) => s.service.name).join(" + "), + status: STATUS_MAP[a.status] ?? "confirmed", + hasNotes: !!a.notes, + hasAvailableReward: rewardFlags.get(a.customerId) ?? false, + })); + + const currentTimeMinutes = + date === today ? minutesFromMidnight(new Date(), staff.business.timezone) : null; + + // Desktop-only: a "Right Now" panel beside the calendar so a wide screen + // does more than center the mobile layout (see docs/phase-5-summary.md, + // "Responsive Design"). Only meaningful when viewing today. + const rightNow = date === today ? selectCurrentAppointment(appointments, new Date()) : null; + + return ( +
+
+

Calendar

+
+ + +
+
+ + + +
+ + Day + + + Agenda + +
+ +
+
+ {wholeDayOff ? ( + + ) : view === "day" ? ( + + ) : calendarAppointments.length === 0 ? ( +
+ +
+ ) : ( +
+ {calendarAppointments.map((a) => ( + + View + + } + /> + ))} +
+ )} +
+ + {/* Desktop-only "Right Now" panel — a wide screen shows the current + appointment alongside the calendar instead of requiring a + separate tab, since there's room for both at once. */} + +
+
+ ); +} + +function formatRange(startMinutes: number, endMinutes: number): string { + const fmt = (m: number) => { + const h = Math.floor(m / 60); + const min = m % 60; + const period = h >= 12 ? "PM" : "AM"; + const displayHour = h % 12 === 0 ? 12 : h % 12; + return `${displayHour}:${String(min).padStart(2, "0")} ${period}`; + }; + return `${fmt(startMinutes)} – ${fmt(endMinutes)}`; } diff --git a/src/app/(staff)/staff/settings/breaks/page.tsx b/src/app/(staff)/staff/settings/breaks/page.tsx new file mode 100644 index 0000000..1b8a6c7 --- /dev/null +++ b/src/app/(staff)/staff/settings/breaks/page.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { listBreaks } from "@/services/staff/staff.service"; +import { BreaksList } from "@/features/staff/components/breaks-list"; + +export const metadata: Metadata = { title: "Breaks" }; +export const dynamic = "force-dynamic"; + +export default async function StaffBreaksPage() { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const breaks = await listBreaks(prisma, staff.id); + + return ( +
+ +
+ ({ + id: b.id, + label: b.label, + startMinutes: b.startMinutes, + endMinutes: b.endMinutes, + days: b.days.map((d) => d.dayOfWeek), + }))} + /> +
+
+ ); +} diff --git a/src/app/(staff)/staff/settings/notifications/page.tsx b/src/app/(staff)/staff/settings/notifications/page.tsx new file mode 100644 index 0000000..664113a --- /dev/null +++ b/src/app/(staff)/staff/settings/notifications/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { getNotificationPreferences } from "@/services/staff/staff.service"; +import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form"; + +export const metadata: Metadata = { title: "Notifications" }; +export const dynamic = "force-dynamic"; + +export default async function StaffNotificationsPage() { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const cells = await getNotificationPreferences(prisma, staff.userId); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/app/(staff)/staff/settings/page.tsx b/src/app/(staff)/staff/settings/page.tsx index 32f58bd..dc78f73 100644 --- a/src/app/(staff)/staff/settings/page.tsx +++ b/src/app/(staff)/staff/settings/page.tsx @@ -1,13 +1,20 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { ShieldCheck } from "lucide-react"; +import { Bell, CalendarClock, CalendarOff, Clock, ShieldCheck, UserCircle } from "lucide-react"; import { auth } from "@/auth"; -import { Button } from "@/components/ui/button"; import { ProfileCard } from "@/components/ui/profile-card"; import { SignOutButton } from "@/features/auth/components/sign-out-button"; import { hasPermission } from "@/lib/auth/permissions"; import { Permission } from "@/generated/prisma/enums"; +const SETTINGS_LINKS = [ + { href: "/staff/settings/working-hours", label: "Working Hours", icon: Clock }, + { href: "/staff/settings/breaks", label: "Breaks", icon: CalendarClock }, + { href: "/staff/settings/time-off", label: "Time Off", icon: CalendarOff }, + { href: "/staff/settings/profile", label: "Profile", icon: UserCircle }, + { href: "/staff/settings/notifications", label: "Notifications", icon: Bell }, +] as const; + export const metadata: Metadata = { title: "Staff Settings" }; export default async function StaffSettingsPage() { @@ -34,9 +41,17 @@ export default async function StaffSettingsPage() { )} - +
    + {SETTINGS_LINKS.map(({ href, label, icon: Icon }) => ( +
  • + +
  • + ))} +
+ ); diff --git a/src/app/(staff)/staff/settings/profile/page.tsx b/src/app/(staff)/staff/settings/profile/page.tsx new file mode 100644 index 0000000..0973a8d --- /dev/null +++ b/src/app/(staff)/staff/settings/profile/page.tsx @@ -0,0 +1,57 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { getStaffProfileByUserId } from "@/services/staff/staff.service"; +import { ProfileForm } from "@/features/staff/components/profile-form"; + +export const metadata: Metadata = { title: "Profile" }; +export const dynamic = "force-dynamic"; + +export default async function StaffProfilePage() { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const profile = await getStaffProfileByUserId(prisma, staff.userId); + if (!profile) redirect("/home"); + + return ( +
+ +
+ + +
+

+ Services You Offer +

+ {profile.services.length === 0 ? ( +

+ No services assigned yet. Ask the owner to add you to a service. +

+ ) : ( +
    + {profile.services.map((s) => ( +
  • + {s.service.name} +
  • + ))} +
+ )} +

+ Only the business owner can change which services you offer. +

+
+
+
+ ); +} diff --git a/src/app/(staff)/staff/settings/time-off/page.tsx b/src/app/(staff)/staff/settings/time-off/page.tsx new file mode 100644 index 0000000..713e97b --- /dev/null +++ b/src/app/(staff)/staff/settings/time-off/page.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { listTimeOff } from "@/services/staff/staff.service"; +import { TimeOffList } from "@/features/staff/components/time-off-list"; + +export const metadata: Metadata = { title: "Time Off" }; +export const dynamic = "force-dynamic"; + +function toCalendarDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +export default async function StaffTimeOffPage() { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const { upcoming, past } = await listTimeOff(prisma, staff.id); + // Whole-day rows only — partial-day "Block Time" entries are managed from the calendar (see block-time-button.tsx). + const wholeDay = (rows: typeof upcoming) => rows.filter((r) => r.startMinutes === null && r.endMinutes === null); + + const toEditable = (rows: typeof upcoming) => + wholeDay(rows).map((t) => ({ + id: t.id, + startDate: toCalendarDate(t.startDate), + endDate: toCalendarDate(t.endDate), + reason: t.reason, + })); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/app/(staff)/staff/settings/working-hours/page.tsx b/src/app/(staff)/staff/settings/working-hours/page.tsx new file mode 100644 index 0000000..86d0370 --- /dev/null +++ b/src/app/(staff)/staff/settings/working-hours/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { getWorkingHours } from "@/services/staff/staff.service"; +import { Weekday } from "@/generated/prisma/enums"; +import { WorkingHoursForm } from "@/features/staff/components/working-hours-form"; + +export const metadata: Metadata = { title: "Working Hours" }; +export const dynamic = "force-dynamic"; + +const DAY_ORDER: Weekday[] = [ + Weekday.MONDAY, + Weekday.TUESDAY, + Weekday.WEDNESDAY, + Weekday.THURSDAY, + Weekday.FRIDAY, + Weekday.SATURDAY, + Weekday.SUNDAY, +]; + +function formatMinutes(m: number): string { + const h = Math.floor(m / 60); + const min = m % 60; + return `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`; +} + +export default async function StaffWorkingHoursPage() { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const [workingHours, businessHours] = await Promise.all([ + getWorkingHours(prisma, staff.id), + prisma.businessHours.findMany({ where: { businessId: staff.businessId } }), + ]); + + const businessHoursLabel = Object.fromEntries( + DAY_ORDER.map((day) => { + const hours = businessHours.find((h) => h.dayOfWeek === day); + const label = !hours || hours.isClosed || hours.opensAtMinutes === null || hours.closesAtMinutes === null ? "Closed" : `${formatMinutes(hours.opensAtMinutes)}–${formatMinutes(hours.closesAtMinutes)}`; + return [day, label]; + }), + ) as Record; + + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/app/book/page.tsx b/src/app/book/page.tsx index d93695a..2862563 100644 --- a/src/app/book/page.tsx +++ b/src/app/book/page.tsx @@ -4,6 +4,7 @@ import { getPrimaryBusiness } from "@/lib/business/get-primary-business"; import { prisma } from "@/lib/db/prisma"; import { getAvailableServices } from "@/services/scheduling/availability.service"; import { ServiceSelector } from "@/features/booking/components/service-selector"; +import { saveServicesStepAction } from "@/features/booking/actions/draft"; export const metadata: Metadata = { title: "Select Services" }; export const dynamic = "force-dynamic"; @@ -22,14 +23,19 @@ export default async function BookServicesPage() { const categoryNames = new Map(categories.map((c) => [c.id, c.name])); const orderedCategoryIds = categories.map((c) => c.id); + async function handleContinue(serviceIds: string[]) { + "use server"; + return saveServicesStepAction({ businessId: business.id, serviceIds }); + } + return ( ); } diff --git a/src/app/staff/appointments/[id]/reschedule/layout.tsx b/src/app/staff/appointments/[id]/reschedule/layout.tsx new file mode 100644 index 0000000..fd771bc --- /dev/null +++ b/src/app/staff/appointments/[id]/reschedule/layout.tsx @@ -0,0 +1,10 @@ +/** + * Deliberately outside the (staff) route group, same reasoning as + * src/app/appointments/[id]/reschedule/layout.tsx on the customer side: + * this page reuses BarberDateTimePicker, whose own fixed bottom action bar + * would otherwise sit under the persistent staff bottom nav and swallow + * clicks meant for "Confirm Reschedule". + */ +export default function StaffRescheduleLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/src/app/staff/appointments/[id]/reschedule/page.tsx b/src/app/staff/appointments/[id]/reschedule/page.tsx new file mode 100644 index 0000000..88b4512 --- /dev/null +++ b/src/app/staff/appointments/[id]/reschedule/page.tsx @@ -0,0 +1,93 @@ +import type { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; +import { Card } from "@/components/ui/card"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { getAppointmentById } from "@/services/scheduling/appointment.service"; +import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization"; +import { rescheduleStaffAppointmentAction } from "@/features/staff/actions/appointment-actions"; +import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker"; + +export const metadata: Metadata = { title: "Reschedule Appointment" }; +export const dynamic = "force-dynamic"; + +const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]); + +export default async function StaffRescheduleAppointmentPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const { id } = await params; + const appointment = await getAppointmentById(prisma, id); + if (!appointment) notFound(); + + try { + assertOwnAppointment(appointment, staff); + } catch (error) { + if (error instanceof StaffPermissionDeniedError) notFound(); + throw error; + } + + if (NON_RESCHEDULABLE.has(appointment.status)) { + redirect(`/staff/appointments/${id}`); + } + + async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) { + "use server"; + const result = await rescheduleStaffAppointmentAction({ + appointmentId: id, + staffId: selection.staffId, + startAt: selection.startAt, + }); + if (result.success) { + redirect(`/staff/appointments/${id}`); + } + return result; + } + + return ( +
+ + + +
+

+ {appointment.services.map((s) => s.service.name).join(" + ")} for{" "} + {appointment.customer.firstName} {appointment.customer.lastName} +

+

+ Currently:{" "} + {appointment.startAt.toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: staff.business.timezone, + })}{" "} + ·{" "} + {appointment.startAt.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: staff.business.timezone, + })} +

+
+
+ +
+ s.serviceId)} + initialStaffId={appointment.staffId} + onContinue={handleContinue} + continueLabel="Confirm Reschedule" + /> +
+
+ ); +} diff --git a/src/app/staff/book/barber-time/page.tsx b/src/app/staff/book/barber-time/page.tsx new file mode 100644 index 0000000..01e9448 --- /dev/null +++ b/src/app/staff/book/barber-time/page.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { TopNav } from "@/components/navigation/top-nav"; +import { readStaffDraft } from "@/lib/booking-draft/cookie"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker"; +import { saveStaffBarberTimeStepAction } from "@/features/staff/actions/staff-booking"; + +export const metadata: Metadata = { title: "Barber & Time" }; +export const dynamic = "force-dynamic"; + +export default async function StaffBookBarberTimePage() { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const draft = await readStaffDraft(staff.businessId); + if (draft.serviceIds.length === 0) redirect("/staff/book/services"); + + async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) { + "use server"; + return saveStaffBarberTimeStepAction(selection); + } + + return ( +
+ + +
+ ); +} diff --git a/src/app/staff/book/layout.tsx b/src/app/staff/book/layout.tsx new file mode 100644 index 0000000..4594b59 --- /dev/null +++ b/src/app/staff/book/layout.tsx @@ -0,0 +1,10 @@ +/** + * Deliberately outside the (staff) route group — same reasoning as + * src/app/book/layout.tsx (customer) and src/app/staff/appointments/[id]/reschedule: + * this flow reuses ServiceSelector and BarberDateTimePicker, both of which + * render their own fixed bottom action bar that would otherwise sit under + * the persistent staff bottom nav. + */ +export default function StaffBookLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/src/app/staff/book/page.tsx b/src/app/staff/book/page.tsx new file mode 100644 index 0000000..0cf0e64 --- /dev/null +++ b/src/app/staff/book/page.tsx @@ -0,0 +1,130 @@ +import type { Metadata } from "next"; +import { Search, UserPlus } from "lucide-react"; +import { redirect } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { matchesCustomerSearch } from "@/services/customer/customer.service"; +import { selectExistingCustomerAction } from "@/features/staff/actions/staff-booking"; +import { NewGuestCustomerForm } from "@/features/staff/components/new-guest-customer-form"; + +export const metadata: Metadata = { title: "Book Appointment" }; +export const dynamic = "force-dynamic"; + +export default async function StaffBookCustomerPage({ + searchParams, +}: { + searchParams: Promise<{ customerId?: string; q?: string }>; +}) { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const { customerId, q } = await searchParams; + + if (customerId) { + const customer = await prisma.customerProfile.findUnique({ where: { id: customerId } }); + if (customer) { + const selectAction = selectExistingCustomerAction.bind(null, customer.id); + return ( +
+ +
+ + + Booking For + +

+ {customer.firstName} {customer.lastName} +

+

{customer.phone ?? customer.email}

+
+
+ +
+
+
+ ); + } + } + + const candidates = q + ? await prisma.customerProfile.findMany({ + where: { appointments: { some: { businessId: staff.businessId } } }, + }) + : []; + const results = candidates.filter((c) => matchesCustomerSearch(c, q ?? "")).slice(0, 20); + + return ( +
+ +
+
+

Find an Existing Customer

+
+
+ +
+

+

+ + + +
+
+
+ ); +} diff --git a/src/app/staff/book/review/page.tsx b/src/app/staff/book/review/page.tsx new file mode 100644 index 0000000..d3f0282 --- /dev/null +++ b/src/app/staff/book/review/page.tsx @@ -0,0 +1,162 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { Card } from "@/components/ui/card"; +import { TopNav } from "@/components/navigation/top-nav"; +import { readStaffDraft } from "@/lib/booking-draft/cookie"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { ConfirmStaffBooking } from "@/features/staff/components/confirm-staff-booking"; + +export const metadata: Metadata = { title: "Review Booking" }; +export const dynamic = "force-dynamic"; + +export default async function StaffBookReviewPage() { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const draft = await readStaffDraft(staff.businessId); + if (draft.serviceIds.length === 0) redirect("/staff/book/services"); + if (!draft.startAt) redirect("/staff/book/barber-time"); + if (!draft.customerId && !draft.guest) redirect("/staff/book"); + + const [services, staffMember, customerProfile] = await Promise.all([ + prisma.service.findMany({ where: { id: { in: draft.serviceIds } } }), + draft.staffId + ? prisma.staffProfile.findUnique({ + where: { id: draft.staffId }, + include: { user: { select: { name: true } } }, + }) + : null, + draft.customerId + ? prisma.customerProfile.findUnique({ where: { id: draft.customerId } }) + : null, + ]); + + const totalDuration = services.reduce((sum, s) => sum + s.durationMinutes, 0); + const totalPrice = services.reduce((sum, s) => sum + Number(s.price), 0); + const startAt = new Date(draft.startAt); + const endAt = new Date(startAt.getTime() + totalDuration * 60_000); + const tz = staff.business.timezone; + + const customerName = draft.guest + ? `${draft.guest.firstName} ${draft.guest.lastName}` + : `${customerProfile?.firstName ?? ""} ${customerProfile?.lastName ?? ""}`.trim(); + const customerContact = draft.guest + ? `${draft.guest.email} · ${draft.guest.phone}` + : (customerProfile?.phone ?? customerProfile?.email ?? ""); + + return ( +
+ +
+ +
+
+ + Customer + +

{customerName}

+

{customerContact}

+
+ + Change + +
+ +
+
+
+ + Services + +
    + {services.map((s) => ( +
  • {s.name}
  • + ))} +
+
+ + Edit + +
+ +
+
+ + Barber + +

+ {staffMember?.user.name ?? "Any available barber"} +

+
+ + Edit + +
+ +
+
+ + Date & Time + +

+ {startAt.toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: tz, + })} + {" · "} + {startAt.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: tz, + })} + – + {endAt.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: tz, + })} +

+
+ + Edit + +
+
+ +
+
+ + Duration + +

{totalDuration} min

+
+
+ + Total + +

£{totalPrice.toFixed(2)}

+
+
+
+ + +
+
+ ); +} diff --git a/src/app/staff/book/services/page.tsx b/src/app/staff/book/services/page.tsx new file mode 100644 index 0000000..5a0abec --- /dev/null +++ b/src/app/staff/book/services/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { readStaffDraft } from "@/lib/booking-draft/cookie"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { getAvailableServices } from "@/services/scheduling/availability.service"; +import { ServiceSelector } from "@/features/booking/components/service-selector"; +import { saveStaffServicesStepAction } from "@/features/staff/actions/staff-booking"; + +export const metadata: Metadata = { title: "Select Services" }; +export const dynamic = "force-dynamic"; + +export default async function StaffBookServicesPage() { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const draft = await readStaffDraft(staff.businessId); + if (!draft.customerId && !draft.guest) redirect("/staff/book"); + + const [services, categories] = await Promise.all([ + getAvailableServices(prisma, { businessId: staff.businessId }), + prisma.serviceCategory.findMany({ + where: { businessId: staff.businessId, isActive: true }, + orderBy: { displayOrder: "asc" }, + }), + ]); + + const categoryNames = Object.fromEntries(categories.map((c) => [c.id, c.name])); + const categoryOrder = categories.map((c) => c.id); + + async function handleContinue(serviceIds: string[]) { + "use server"; + return saveStaffServicesStepAction({ serviceIds }); + } + + return ( + + ); +} diff --git a/src/app/style-guide/page.tsx b/src/app/style-guide/page.tsx index 1f8d345..4c383ae 100644 --- a/src/app/style-guide/page.tsx +++ b/src/app/style-guide/page.tsx @@ -120,7 +120,7 @@ export default function StyleGuidePage() { ; -export type BottomNavIconName = keyof typeof ICON_MAP; +export type BottomNavIconName = keyof typeof NAV_ICON_MAP; export interface BottomNavItem { href: string; @@ -61,6 +61,11 @@ export function BottomNav({ items, variant = "customer" }: BottomNavProps) { "fixed inset-x-0 bottom-0 z-50 border-t px-5 py-2", "pb-[max(0.5rem,env(safe-area-inset-bottom))]", isStaff ? "border-white/5 bg-[#0c0c0c] text-gray-400" : "border-line text-muted bg-white", + // Staff/owner get a persistent left sidebar at lg+ instead (see + // staff-sidebar-nav.tsx) — a bottom bar doesn't earn its keep once + // there's room for one-hand-unnecessary desktop chrome. Customer + // nav stays bottom-anchored at every size; that's Phase 4 scope. + isStaff && "lg:hidden", )} > {/* The bar itself stays edge-to-edge (matches the booking flow's fixed @@ -68,7 +73,7 @@ export function BottomNav({ items, variant = "customer" }: BottomNavProps) { they don't stretch out unreadably on a wide desktop viewport. */}
{items.map(({ href, label, icon }) => { - const Icon = ICON_MAP[icon]; + const Icon = NAV_ICON_MAP[icon]; const active = pathname === href || pathname.startsWith(`${href}/`); return ( + + {children} + +
+ ); +} diff --git a/src/components/navigation/staff-sidebar-nav.tsx b/src/components/navigation/staff-sidebar-nav.tsx new file mode 100644 index 0000000..194d2b3 --- /dev/null +++ b/src/components/navigation/staff-sidebar-nav.tsx @@ -0,0 +1,52 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Scissors } from "lucide-react"; +import { cn } from "@/lib/utils/cn"; +import { NAV_ICON_MAP } from "./bottom-nav"; +import type { BottomNavItem } from "./bottom-nav"; + +/** + * The desktop counterpart to BottomNav's staff variant — a persistent left + * rail instead of a bottom bar, shown only at lg+ (BottomNav hides itself + * there in turn, see bottom-nav.tsx). Same dark palette, same nav items, + * same active-route logic; just a different shape for a screen with room + * to spare, per the brief's explicit call-out that staff desktop needs + * more than the mobile bar simply centered. + */ +export function StaffSidebarNav({ items }: { items: BottomNavItem[] }) { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/components/ui/appointment-card.tsx b/src/components/ui/appointment-card.tsx index 3574ab4..6fa1837 100644 --- a/src/components/ui/appointment-card.tsx +++ b/src/components/ui/appointment-card.tsx @@ -1,4 +1,5 @@ import Image from "next/image"; +import { Gift, NotebookText } from "lucide-react"; import type { ReactNode } from "react"; import { Card } from "@/components/ui/card"; import { StatusPill } from "@/components/ui/status-pill"; @@ -7,11 +8,18 @@ import type { AppointmentStatus, StaffSummary } from "@/types/domain"; export interface AppointmentCardProps { status: AppointmentStatus; serviceName: string; - staff: StaffSummary; - /** Pre-formatted for display, e.g. "Sat, Oct 12". */ - formattedDate: string; + /** The other party shown on the card — the barber on a customer-facing card, the customer on a staff-facing one. Same {id, name, avatarUrl} shape either way. */ + person: StaffSummary; + /** Defaults to "with" — a staff card might prefer e.g. "for". */ + personLabel?: string; + /** Pre-formatted for display, e.g. "Sat, Oct 12" — omit when the surrounding page already makes the date clear (e.g. a single day's agenda). */ + formattedDate?: string; /** Pre-formatted for display, e.g. "2:00 PM". */ formattedTime: string; + /** Small notes-exist indicator — staff calendar/agenda use, customer cards leave this unset. */ + hasNotes?: boolean; + /** Small loyalty-reward-available indicator — same. */ + hasAvailableReward?: boolean; /** Reschedule/Cancel/Book again buttons — left to the caller so this * component stays agnostic of which actions apply to which status. */ actions?: ReactNode; @@ -21,9 +29,12 @@ export interface AppointmentCardProps { export function AppointmentCard({ status, serviceName, - staff, + person, + personLabel = "with", formattedDate, formattedTime, + hasNotes, + hasAvailableReward, actions, }: AppointmentCardProps) { return ( @@ -31,30 +42,43 @@ export function AppointmentCard({
-

{formattedDate}

-

{formattedTime}

+ {formattedDate &&

{formattedDate}

} +

{formattedTime}

- {staff.avatarUrl ? ( + {person.avatarUrl ? ( ) : ( -
- {staff.name.charAt(0)} +
+ {person.name.charAt(0)}
)} -
-

{serviceName}

-

with {staff.name}

+
+
+

{serviceName}

+ {hasNotes && ( + + )} + {hasAvailableReward && ( + + )} +
+

+ {personLabel} {person.name} +

- {actions &&
{actions}
} + {actions &&
{actions}
} ); } diff --git a/src/components/ui/status-pill.tsx b/src/components/ui/status-pill.tsx index 0a79a62..6a56763 100644 --- a/src/components/ui/status-pill.tsx +++ b/src/components/ui/status-pill.tsx @@ -1,36 +1,74 @@ +import { Ban, CheckCheck, CircleDot, Clock, LogIn, UserX } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import type { AppointmentStatus } from "@/types/domain"; import { cn } from "@/lib/utils/cn"; -const STATUS_CONFIG: Record = { +/** + * Icon + text + colour, never colour alone — the barber calendar packs + * these into small blocks where colour alone would be genuinely hard to + * tell apart at a glance (see docs/phase-5-summary.md, Accessibility). + * Reused as-is on the customer side too, where the icon is a small, + * harmless addition to a pill that already carried a text label. + */ +const STATUS_CONFIG: Record< + AppointmentStatus, + { label: string; className: string; icon: LucideIcon } +> = { pending_approval: { label: "Pending Approval", className: "bg-status-pending/10 text-status-pending", + icon: Clock, + }, + confirmed: { + label: "Confirmed", + className: "bg-status-confirmed/10 text-status-confirmed", + icon: CheckCheck, + }, + arrived: { + label: "Arrived", + className: "bg-status-confirmed/10 text-status-confirmed", + icon: LogIn, + }, + in_progress: { + label: "In Progress", + className: "bg-status-progress/10 text-status-progress", + icon: CircleDot, + }, + completed: { label: "Completed", className: "bg-stone text-muted", icon: CheckCheck }, + cancelled: { + label: "Cancelled", + className: "bg-status-cancelled/10 text-status-cancelled", + icon: Ban, + }, + no_show: { + label: "No-show", + className: "bg-status-cancelled/10 text-status-cancelled", + icon: UserX, }, - confirmed: { label: "Confirmed", className: "bg-status-confirmed/10 text-status-confirmed" }, - arrived: { label: "Arrived", className: "bg-status-confirmed/10 text-status-confirmed" }, - in_progress: { label: "In Progress", className: "bg-status-progress/10 text-status-progress" }, - completed: { label: "Completed", className: "bg-stone text-muted" }, - cancelled: { label: "Cancelled", className: "bg-status-cancelled/10 text-status-cancelled" }, - no_show: { label: "No-show", className: "bg-status-cancelled/10 text-status-cancelled" }, }; export function StatusPill({ status, className, + iconOnly, }: { status: AppointmentStatus; className?: string; + /** Renders just the icon with a visually-hidden label — for tight spaces like calendar blocks. */ + iconOnly?: boolean; }) { const config = STATUS_CONFIG[status]; + const Icon = config.icon; return ( - {config.label} + ); } diff --git a/src/features/booking/components/service-selector.tsx b/src/features/booking/components/service-selector.tsx index 5b54d3b..096bf13 100644 --- a/src/features/booking/components/service-selector.tsx +++ b/src/features/booking/components/service-selector.tsx @@ -4,24 +4,24 @@ import { Plus, Check } from "lucide-react"; import { useMemo, useState, useTransition } from "react"; import { Button } from "@/components/ui/button"; import { TopNav } from "@/components/navigation/top-nav"; -import { saveServicesStepAction } from "../actions/draft"; import type { AvailableService } from "@/services/scheduling/availability.service"; export function ServiceSelector(props: { - businessId: string; services: AvailableService[]; categoryNames: Record; categoryOrder: string[]; initialSelectedServiceIds: string[]; rebooking: boolean; + /** Injected rather than imported directly (same pattern as BarberDateTimePicker's onContinue) — the customer flow and the Phase 5 staff "book for customer" flow each write to their own draft cookie via a different Server Action, see docs/phase-5-summary.md. */ + onContinue: (serviceIds: string[]) => Promise<{ success: boolean; error?: string }>; }) { const { - businessId, services, categoryNames, categoryOrder, initialSelectedServiceIds, rebooking, + onContinue, } = props; const [selectedIds, setSelectedIds] = useState(initialSelectedServiceIds); const [error, setError] = useState(null); @@ -57,7 +57,7 @@ export function ServiceSelector(props: { const handleContinue = () => { setError(null); startTransition(async () => { - const result = await saveServicesStepAction({ businessId, serviceIds: selectedIds }); + const result = await onContinue(selectedIds); if (!result.success) setError(result.error ?? "Please select at least one service."); }); }; diff --git a/src/features/staff/__tests__/authorization.test.ts b/src/features/staff/__tests__/authorization.test.ts new file mode 100644 index 0000000..71fc789 --- /dev/null +++ b/src/features/staff/__tests__/authorization.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + StaffPermissionDeniedError, + assertAppointmentInBusiness, + assertOwnAppointment, + assertOwnNote, +} from "../authorization"; + +const staff = { id: "staff_1", businessId: "biz_1" }; +const otherStaff = { id: "staff_2", businessId: "biz_1" }; + +describe("assertAppointmentInBusiness", () => { + it("allows an appointment in the staff member's own business", () => { + expect(() => + assertAppointmentInBusiness({ businessId: "biz_1", staffId: "staff_9" }, staff), + ).not.toThrow(); + }); + + it("denies an appointment from a different business, even if the staffId happened to match", () => { + expect(() => + assertAppointmentInBusiness({ businessId: "biz_2", staffId: "staff_1" }, staff), + ).toThrow(StaffPermissionDeniedError); + }); +}); + +describe("assertOwnAppointment", () => { + it("allows the assigned staff member to manage their own appointment", () => { + expect(() => + assertOwnAppointment({ businessId: "biz_1", staffId: "staff_1" }, staff), + ).not.toThrow(); + }); + + it("denies another staff member in the same business from managing it", () => { + expect(() => + assertOwnAppointment({ businessId: "biz_1", staffId: "staff_1" }, otherStaff), + ).toThrow(StaffPermissionDeniedError); + }); + + it("denies a cross-business appointment even for the matching staffId", () => { + expect(() => + assertOwnAppointment({ businessId: "biz_2", staffId: "staff_1" }, staff), + ).toThrow(StaffPermissionDeniedError); + }); +}); + +describe("assertOwnNote", () => { + it("allows the author to act on their own note", () => { + expect(() => assertOwnNote({ authorStaffId: "staff_1" }, staff)).not.toThrow(); + }); + + it("denies a staff member editing another staff member's note", () => { + expect(() => assertOwnNote({ authorStaffId: "staff_1" }, otherStaff)).toThrow( + StaffPermissionDeniedError, + ); + }); +}); diff --git a/src/features/staff/actions/appointment-actions.ts b/src/features/staff/actions/appointment-actions.ts new file mode 100644 index 0000000..0aca5b5 --- /dev/null +++ b/src/features/staff/actions/appointment-actions.ts @@ -0,0 +1,169 @@ +"use server"; + +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service"; +import { + checkIn, + complete, + confirm, + getAppointmentById, + markNoShow, + start, +} from "@/services/scheduling/appointment.service"; +import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service"; +import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization"; + +/** + * Every status transition here does exactly three things: load the + * appointment, assert it's this staff member's own (in their own + * business), and delegate to AppointmentService/BookingService for the + * actual transition — the transition rules themselves (which statuses can + * move to which) live entirely in AppointmentService's own state machine, + * never re-implemented here. Completing an appointment additionally calls + * LoyaltyService, since "complete this visit" and "award a stamp for it" + * are two different domain responsibilities this layer is allowed to + * orchestrate together — see docs/phase-5-summary.md. + */ + +export interface StaffAppointmentActionResult { + success: boolean; + error?: string; + loyaltyStampAwarded?: boolean; +} + +async function loadOwnAppointment(appointmentId: string) { + const staff = await getActingStaff(); + if (!staff) + return { staff: null, appointment: null, error: "You must be signed in as staff." } as const; + + const appointment = await getAppointmentById(prisma, appointmentId); + if (!appointment) return { staff, appointment: null, error: "Appointment not found." } as const; + + try { + assertOwnAppointment(appointment, staff); + } catch (error) { + if (error instanceof StaffPermissionDeniedError) + return { staff, appointment: null, error: error.message } as const; + throw error; + } + + return { staff, appointment, error: null } as const; +} + +export async function confirmAppointmentAction( + appointmentId: string, +): Promise { + const { appointment, error } = await loadOwnAppointment(appointmentId); + if (!appointment) return { success: false, error: error ?? "Not found." }; + + const result = await confirm(prisma, appointmentId); + return result.success + ? { success: true } + : { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) }; +} + +export async function checkInAppointmentAction( + appointmentId: string, +): Promise { + const { appointment, error } = await loadOwnAppointment(appointmentId); + if (!appointment) return { success: false, error: error ?? "Not found." }; + + const result = await checkIn(prisma, appointmentId); + return result.success + ? { success: true } + : { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) }; +} + +export async function startAppointmentAction( + appointmentId: string, +): Promise { + const { appointment, error } = await loadOwnAppointment(appointmentId); + if (!appointment) return { success: false, error: error ?? "Not found." }; + + const result = await start(prisma, appointmentId); + return result.success + ? { success: true } + : { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) }; +} + +export async function completeAppointmentAction( + appointmentId: string, +): Promise { + const { staff, appointment, error } = await loadOwnAppointment(appointmentId); + if (!appointment || !staff) return { success: false, error: error ?? "Not found." }; + + const result = await complete(prisma, appointmentId); + if (!result.success) { + return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) }; + } + + const loyaltyResult = await awardStampForCompletedAppointment(prisma, { + appointmentId, + staffId: staff.id, + }); + return { + success: true, + loyaltyStampAwarded: loyaltyResult.success && !loyaltyResult.alreadyAwarded, + }; +} + +export async function markNoShowAction( + appointmentId: string, +): Promise { + const { appointment, error } = await loadOwnAppointment(appointmentId); + if (!appointment) return { success: false, error: error ?? "Not found." }; + + const result = await markNoShow(prisma, appointmentId); + return result.success + ? { success: true } + : { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) }; +} + +export async function cancelStaffAppointmentAction(params: { + appointmentId: string; + reason?: string; +}): Promise { + const { staff, appointment, error } = await loadOwnAppointment(params.appointmentId); + if (!appointment || !staff) return { success: false, error: error ?? "Not found." }; + + const result = await cancelBooking(prisma, { + appointmentId: params.appointmentId, + cancelledByUserId: staff.user.id, + reason: params.reason, + }); + return result.success + ? { success: true } + : { + success: false, + error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment.", + }; +} + +export async function rescheduleStaffAppointmentAction(params: { + appointmentId: string; + staffId?: string; + startAt: string; +}): Promise { + const { appointment, error } = await loadOwnAppointment(params.appointmentId); + if (!appointment) return { success: false, error: error ?? "Not found." }; + + const result = await rescheduleBooking(prisma, { + appointmentId: params.appointmentId, + staffId: params.staffId, + startAt: new Date(params.startAt), + }); + return result.success + ? { success: true } + : { + success: false, + error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment.", + }; +} + +function friendlyTransitionMessage(code?: string): string { + if (code === "INVALID_STATUS_TRANSITION") + return "This appointment can't move to that status right now — someone may have already updated it."; + if (code === "APPOINTMENT_NOT_FOUND") return "We couldn't find that appointment."; + return "Something went wrong. Please try again."; +} diff --git a/src/features/staff/actions/block-time.ts b/src/features/staff/actions/block-time.ts new file mode 100644 index 0000000..dcb0316 --- /dev/null +++ b/src/features/staff/actions/block-time.ts @@ -0,0 +1,58 @@ +"use server"; + +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { createTimeOff } from "@/services/staff/staff.service"; + +export interface BlockTimeConflict { + id: string; + customerName: string; + serviceNames: string; + startAt: string; +} + +export interface BlockTimeActionResult { + success: boolean; + error?: string; + conflicts?: BlockTimeConflict[]; +} + +/** One-off "Block Time" from the calendar — represented as a partial-day TimeOff row, see schema.prisma and docs/phase-5-summary.md. */ +export async function blockTimeAction(input: { + date: string; + startMinutes: number; + endMinutes: number; + reason: string; + acknowledgeConflicts?: boolean; +}): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff to do that." }; + + const result = await createTimeOff(prisma, { + staffId: staff.id, + businessTimezone: staff.business.timezone, + input: { + startDate: input.date, + endDate: input.date, + reason: input.reason.trim() || "Blocked", + startMinutes: input.startMinutes, + endMinutes: input.endMinutes, + }, + acknowledgeConflicts: input.acknowledgeConflicts, + }); + + if (!result.success) { + return { + success: false, + error: result.validation.issues[0]?.message ?? "Something went wrong. Please try again.", + conflicts: result.conflicts?.map((c) => ({ + id: c.id, + customerName: `${c.customer.firstName} ${c.customer.lastName}`, + serviceNames: c.services.map((s) => s.service.name).join(" + "), + startAt: c.startAt.toISOString(), + })), + }; + } + + return { success: true }; +} diff --git a/src/features/staff/actions/breaks.ts b/src/features/staff/actions/breaks.ts new file mode 100644 index 0000000..2b40428 --- /dev/null +++ b/src/features/staff/actions/breaks.ts @@ -0,0 +1,58 @@ +"use server"; + +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { createBreak, deleteBreak, updateBreak } from "@/services/staff/staff.service"; +import type { BreakInput } from "@/services/staff/staff.service"; + +export interface BreakConflictItem { + id: string; + customerName: string; + serviceNames: string; + startAt: string; +} + +export interface BreakActionResult { + success: boolean; + error?: string; + conflicts?: BreakConflictItem[]; +} + +function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): BreakConflictItem[] { + return conflicts.map((c) => ({ + id: c.id, + customerName: `${c.customer.firstName} ${c.customer.lastName}`, + serviceNames: c.services.map((s) => s.service.name).join(" + "), + startAt: c.startAt.toISOString(), + })); +} + +export async function createBreakAction(input: BreakInput, acknowledgeConflicts?: boolean): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + const result = await createBreak(prisma, { staffId: staff.id, businessTimezone: staff.business.timezone, input, acknowledgeConflicts }); + if (!result.success) { + return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create this break.", conflicts: mapConflicts(result.conflicts) }; + } + return { success: true }; +} + +export async function updateBreakAction(breakId: string, input: BreakInput, acknowledgeConflicts?: boolean): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + const result = await updateBreak(prisma, { breakId, staffId: staff.id, businessTimezone: staff.business.timezone, input, acknowledgeConflicts }); + if (!result.success) { + return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this break.", conflicts: mapConflicts(result.conflicts) }; + } + return { success: true }; +} + +export async function deleteBreakAction(breakId: string): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + const result = await deleteBreak(prisma, { breakId, staffId: staff.id }); + return result.success ? { success: true } : { success: false, error: "Couldn't delete this break." }; +} diff --git a/src/features/staff/actions/customer-notes.ts b/src/features/staff/actions/customer-notes.ts new file mode 100644 index 0000000..54b0889 --- /dev/null +++ b/src/features/staff/actions/customer-notes.ts @@ -0,0 +1,107 @@ +"use server"; + +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { assertOwnNote, StaffPermissionDeniedError } from "../authorization"; + +/** + * Note permission model (documented per the Phase 5 brief's "choose a + * sensible permission model and document it"): + * - Any staff member in the business may ADD a note — notes are shared + * context for whoever's working with this customer next, not private. + * - Only the AUTHOR may edit or delete their own note — prevents one + * staff member silently rewriting or erasing another's observation. + * - Edits and deletes are recorded to the existing AuditLog table (see + * schema.prisma — built in Phase 2, unused until now) rather than a new + * edit-history table; CustomerNote.updatedAt alone tells a reader "this + * was edited", AuditLog keeps the discarded content in case it matters + * later. Notes are never silently overwritten with no trace. + */ + +export interface CustomerNoteActionResult { + success: boolean; + error?: string; +} + +export async function addCustomerNoteAction(params: { + customerId: string; + body: string; +}): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + if (!params.body.trim()) return { success: false, error: "Note can't be empty." }; + + await prisma.customerNote.create({ + data: { customerId: params.customerId, authorStaffId: staff.id, body: params.body.trim() }, + }); + return { success: true }; +} + +export async function updateCustomerNoteAction(params: { + noteId: string; + body: string; +}): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + if (!params.body.trim()) return { success: false, error: "Note can't be empty." }; + + const existing = await prisma.customerNote.findUnique({ where: { id: params.noteId } }); + if (!existing) return { success: false, error: "Note not found." }; + + try { + assertOwnNote(existing, staff); + } catch (error) { + if (error instanceof StaffPermissionDeniedError) + return { success: false, error: error.message }; + throw error; + } + + await prisma.$transaction([ + prisma.customerNote.update({ + where: { id: params.noteId }, + data: { body: params.body.trim() }, + }), + prisma.auditLog.create({ + data: { + actorUserId: staff.user.id, + action: "UPDATE", + entityType: "CustomerNote", + entityId: params.noteId, + metadata: { previousBody: existing.body }, + }, + }), + ]); + return { success: true }; +} + +export async function deleteCustomerNoteAction(params: { + noteId: string; +}): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + const existing = await prisma.customerNote.findUnique({ where: { id: params.noteId } }); + if (!existing) return { success: false, error: "Note not found." }; + + try { + assertOwnNote(existing, staff); + } catch (error) { + if (error instanceof StaffPermissionDeniedError) + return { success: false, error: error.message }; + throw error; + } + + await prisma.$transaction([ + prisma.auditLog.create({ + data: { + actorUserId: staff.user.id, + action: "DELETE", + entityType: "CustomerNote", + entityId: params.noteId, + metadata: { body: existing.body, customerId: existing.customerId }, + }, + }), + prisma.customerNote.delete({ where: { id: params.noteId } }), + ]); + return { success: true }; +} diff --git a/src/features/staff/actions/loyalty-actions.ts b/src/features/staff/actions/loyalty-actions.ts new file mode 100644 index 0000000..b06ff2d --- /dev/null +++ b/src/features/staff/actions/loyalty-actions.ts @@ -0,0 +1,57 @@ +"use server"; + +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { addStamp, correctStamp } from "@/services/loyalty/loyalty.service"; + +export interface LoyaltyActionResult { + success: boolean; + error?: string; + rewardCreated?: boolean; +} + +export async function addLoyaltyStampAction(params: { + customerId: string; + amount: number; + appointmentId?: string; + reason?: string; +}): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + const result = await addStamp(prisma, { + customerId: params.customerId, + businessId: staff.businessId, + amount: params.amount, + staffId: staff.id, + appointmentId: params.appointmentId, + reason: params.reason, + }); + + return result.success + ? { success: true, rewardCreated: result.rewardCreated } + : { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add a stamp." }; +} + +export async function correctLoyaltyStampAction(params: { + loyaltyAccountId: string; + amount: number; + reason: string; +}): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + const result = await correctStamp(prisma, { + loyaltyAccountId: params.loyaltyAccountId, + amount: params.amount, + staffId: staff.id, + reason: params.reason, + }); + + return result.success + ? { success: true } + : { + success: false, + error: result.validation.issues[0]?.message ?? "Couldn't correct that stamp.", + }; +} diff --git a/src/features/staff/actions/profile.ts b/src/features/staff/actions/profile.ts new file mode 100644 index 0000000..4e06703 --- /dev/null +++ b/src/features/staff/actions/profile.ts @@ -0,0 +1,45 @@ +"use server"; + +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { setNotificationPreferences, updateStaffProfile } from "@/services/staff/staff.service"; +import type { NotificationPreferenceCell } from "@/services/staff/staff.service"; + +export interface ProfileActionResult { + success: boolean; + error?: string; +} + +export async function updateProfileAction(input: { + name: string; + phone: string; + image: string; + bio: string; + isBookableOnline: boolean; +}): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + if (!input.name.trim()) return { success: false, error: "Name can't be empty." }; + + const result = await updateStaffProfile(prisma, { + staffId: staff.id, + name: input.name.trim(), + phone: input.phone.trim(), + image: input.image.trim(), + bio: input.bio.trim(), + isBookableOnline: input.isBookableOnline, + }); + + return result.success ? { success: true } : { success: false, error: "Couldn't save your profile." }; +} + +export async function saveNotificationPreferencesAction( + cells: NotificationPreferenceCell[], +): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + await setNotificationPreferences(prisma, staff.userId, cells); + return { success: true }; +} diff --git a/src/features/staff/actions/staff-booking.ts b/src/features/staff/actions/staff-booking.ts new file mode 100644 index 0000000..087e928 --- /dev/null +++ b/src/features/staff/actions/staff-booking.ts @@ -0,0 +1,221 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { + clearStaffDraft, + readStaffDraft, + writeLastBookingReference, + writeStaffDraft, +} from "@/lib/booking-draft/cookie"; +import { guestDetailsSchema } from "@/lib/booking-draft/schema"; +import { getFirstFriendlyMessage } from "@/features/booking/error-messages"; +import { resolveCustomerForBooking } from "@/services/customer/customer.service"; +import { createBooking } from "@/services/scheduling/booking.service"; +import { validateBookingSlot } from "@/services/scheduling/availability.service"; +import { getAppointmentById } from "@/services/scheduling/appointment.service"; +import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization"; + +/** + * The staff "book for customer" flow — collects input across a few steps + * (same signed-draft-cookie pattern as the customer flow, see + * src/lib/booking-draft), then hands off entirely to BookingService at + * confirm time. No availability or validation logic lives here; every + * check either already happened in AvailabilityService (via + * validateBookingSlot, for a better error message earlier) or happens + * again, authoritatively, inside BookingService.createBooking itself. + */ + +export interface StaffBookingStepResult { + success: boolean; + error?: string; + fieldErrors?: Record; +} + +export async function selectExistingCustomerAction(customerId: string): Promise { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const draft = await readStaffDraft(staff.businessId); + await writeStaffDraft({ ...draft, businessId: staff.businessId, customerId, guest: undefined }); + redirect("/staff/book/services"); +} + +export async function selectNewGuestCustomerAction( + input: unknown, +): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + const parsed = guestDetailsSchema.safeParse(input); + if (!parsed.success) { + return { + success: false, + error: "Please check the highlighted fields.", + fieldErrors: parsed.error.flatten().fieldErrors, + }; + } + + const draft = await readStaffDraft(staff.businessId); + await writeStaffDraft({ + ...draft, + businessId: staff.businessId, + guest: parsed.data, + customerId: undefined, + }); + redirect("/staff/book/services"); +} + +export async function saveStaffServicesStepAction(input: { + serviceIds: string[]; +}): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + if (input.serviceIds.length === 0) + return { success: false, error: "Please select at least one service." }; + + const draft = await readStaffDraft(staff.businessId); + if (!draft.customerId && !draft.guest) redirect("/staff/book"); + + const previousServiceIds = [...draft.serviceIds].sort().join(","); + const nextServiceIds = [...input.serviceIds].sort().join(","); + const servicesChanged = previousServiceIds !== nextServiceIds; + + await writeStaffDraft({ + ...draft, + businessId: staff.businessId, + serviceIds: input.serviceIds, + ...(servicesChanged ? { staffId: undefined, date: undefined, startAt: undefined } : {}), + }); + redirect("/staff/book/barber-time"); +} + +export async function saveStaffBarberTimeStepAction(input: { + staffId?: string; + date: string; + startAt: string; +}): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + const draft = await readStaffDraft(staff.businessId); + if (draft.serviceIds.length === 0) + return { success: false, error: "Please select a service first." }; + + // Only pre-validate a specific staff pick — "any available barber" is + // resolved by BookingService itself at confirm time, same reasoning as + // the customer flow's equivalent step (see draft.ts). + if (input.staffId) { + const { validation } = await validateBookingSlot(prisma, { + businessId: staff.businessId, + serviceIds: draft.serviceIds, + staffId: input.staffId, + startAt: new Date(input.startAt), + }); + if (!validation.valid) return { success: false, error: getFirstFriendlyMessage(validation) }; + } + + await writeStaffDraft({ + ...draft, + staffId: input.staffId, + date: input.date, + startAt: input.startAt, + }); + redirect("/staff/book/review"); +} + +export async function saveStaffNotesAction(notes: string): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + const draft = await readStaffDraft(staff.businessId); + await writeStaffDraft({ ...draft, notes: notes || undefined }); + return { success: true }; +} + +export async function confirmStaffBookingAction(notes?: string): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + const draft = await readStaffDraft(staff.businessId); + if (draft.serviceIds.length === 0 || !draft.startAt) { + return { success: false, error: "Booking details are incomplete — please start again." }; + } + if (!draft.customerId && !draft.guest) { + return { success: false, error: "Please select or add a customer first." }; + } + + let customerId: string; + try { + if (draft.customerId) { + customerId = draft.customerId; + } else { + const resolved = await resolveCustomerForBooking(prisma, { + businessId: staff.businessId, + guest: draft.guest, + }); + customerId = resolved.customerId; + } + } catch { + return { success: false, error: "We couldn't resolve the customer. Please try again." }; + } + + const result = await createBooking(prisma, { + businessId: staff.businessId, + customerId, + serviceIds: draft.serviceIds, + staffId: draft.staffId, + startAt: new Date(draft.startAt), + notes: notes || draft.notes, + createdByUserId: staff.user.id, + }); + + if (!result.success) { + return { success: false, error: getFirstFriendlyMessage(result.validation) }; + } + + await writeLastBookingReference(result.appointment.id); + await clearStaffDraft(); + redirect(`/staff/appointments/${result.appointment.id}`); +} + +/** + * "Book Follow-Up" — preselects customer, services, and barber from a + * past appointment, then sends the barber straight to the services step + * (skipping customer selection, since it's already known). Prices and + * availability are recalculated fresh at every later step; nothing here + * copies the old appointment's snapshot values. + */ +export async function startStaffFollowUpAction(appointmentId: string): Promise { + const staff = await getActingStaff(); + if (!staff) redirect("/home"); + + const appointment = await getAppointmentById(prisma, appointmentId); + if (!appointment) redirect("/staff/current"); + + try { + assertOwnAppointment(appointment, staff); + } catch (error) { + if (error instanceof StaffPermissionDeniedError) redirect("/staff/current"); + throw error; + } + + const activeServiceIds = appointment.services + .filter((line) => line.service.isActive) + .map((line) => line.serviceId); + const staffId = + appointment.staff.status === "ACTIVE" && appointment.staff.isBookableOnline + ? appointment.staffId + : undefined; + + await clearStaffDraft(); + await writeStaffDraft({ + businessId: appointment.businessId, + serviceIds: activeServiceIds, + staffId, + customerId: appointment.customerId, + followUpFromAppointmentId: appointment.id, + }); + + redirect("/staff/book/services"); +} diff --git a/src/features/staff/actions/time-off.ts b/src/features/staff/actions/time-off.ts new file mode 100644 index 0000000..450ef43 --- /dev/null +++ b/src/features/staff/actions/time-off.ts @@ -0,0 +1,77 @@ +"use server"; + +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service"; +import type { TimeOffInput } from "@/services/staff/staff.service"; + +export interface TimeOffConflictItem { + id: string; + customerName: string; + serviceNames: string; + startAt: string; +} + +export interface TimeOffActionResult { + success: boolean; + error?: string; + conflicts?: TimeOffConflictItem[]; +} + +function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): TimeOffConflictItem[] { + return conflicts.map((c) => ({ + id: c.id, + customerName: `${c.customer.firstName} ${c.customer.lastName}`, + serviceNames: c.services.map((s) => s.service.name).join(" + "), + startAt: c.startAt.toISOString(), + })); +} + +/** Whole-day time off only — the settings editor doesn't handle partial-day blocks; that's the calendar's "Block Time" action (see block-time.ts). */ +export async function createTimeOffAction( + input: Pick, + acknowledgeConflicts?: boolean, +): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + const result = await createTimeOff(prisma, { + staffId: staff.id, + businessTimezone: staff.business.timezone, + input, + acknowledgeConflicts, + }); + if (!result.success) { + return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this time off.", conflicts: mapConflicts(result.conflicts) }; + } + return { success: true }; +} + +export async function updateTimeOffAction( + timeOffId: string, + input: Pick, + acknowledgeConflicts?: boolean, +): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + const result = await updateTimeOff(prisma, { + timeOffId, + staffId: staff.id, + businessTimezone: staff.business.timezone, + input, + acknowledgeConflicts, + }); + if (!result.success) { + return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this time off.", conflicts: mapConflicts(result.conflicts) }; + } + return { success: true }; +} + +export async function deleteTimeOffAction(timeOffId: string): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + const result = await deleteTimeOff(prisma, { timeOffId, staffId: staff.id }); + return result.success ? { success: true } : { success: false, error: "Couldn't remove this time off." }; +} diff --git a/src/features/staff/actions/working-hours.ts b/src/features/staff/actions/working-hours.ts new file mode 100644 index 0000000..ca1bf68 --- /dev/null +++ b/src/features/staff/actions/working-hours.ts @@ -0,0 +1,22 @@ +"use server"; + +import { prisma } from "@/lib/db/prisma"; +import { getActingStaff } from "@/lib/staff/get-acting-staff"; +import { setWorkingHours } from "@/services/staff/staff.service"; +import type { WorkingHoursDayInput } from "@/services/staff/staff.service"; + +export interface WorkingHoursActionResult { + success: boolean; + error?: string; +} + +export async function saveWorkingHoursAction(days: WorkingHoursDayInput[]): Promise { + const staff = await getActingStaff(); + if (!staff) return { success: false, error: "You must be signed in as staff." }; + + const result = await setWorkingHours(prisma, { staffId: staff.id, days }); + if (!result.success) { + return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save working hours." }; + } + return { success: true }; +} diff --git a/src/features/staff/authorization.ts b/src/features/staff/authorization.ts new file mode 100644 index 0000000..0423388 --- /dev/null +++ b/src/features/staff/authorization.ts @@ -0,0 +1,64 @@ +/** + * Staff-facing ownership checks — the mirror of + * src/features/booking/authorization.ts for the barber section. Kept in + * the feature layer, not a domain service, for the same reason as the + * customer version: AppointmentService/CustomerNote etc. are reused by + * multiple future permission contexts (a future owner all-staff view will + * need different rules), so "can THIS staff member touch THIS resource" + * lives next to the staff actions that need it, not inside the services. + * + * Business scoping note: CustomerProfile has no businessId column (see + * schema.prisma) — this is the same single-business v1 assumption Phase 4 + * documented for the customer app. Every appointment IS businessId-scoped, + * so appointment checks below are real; a customer-record check would be a + * no-op given the current schema and isn't included for that reason. + */ + +export class StaffPermissionDeniedError extends Error { + constructor(message = "You don't have permission to do that.") { + super(message); + this.name = "StaffPermissionDeniedError"; + } +} + +export interface StaffOwnableAppointment { + businessId: string; + staffId: string; +} + +export interface ActingStaff { + id: string; + businessId: string; +} + +/** Viewing (details, calendar, customer history) only requires the appointment to belong to the staff member's own business — see the class doc for why "own business" is the only real scoping this phase (no all-staff owner calendar built yet). */ +export function assertAppointmentInBusiness( + appointment: StaffOwnableAppointment, + staff: ActingStaff, +): void { + if (appointment.businessId !== staff.businessId) { + throw new StaffPermissionDeniedError("This appointment doesn't belong to your business."); + } +} + +/** Mutating an appointment (status transitions, reschedule, cancel) additionally requires it to be assigned to THIS staff member — Phase 5 scope is "manage appointments assigned to them", not any staff member's. */ +export function assertOwnAppointment( + appointment: StaffOwnableAppointment, + staff: ActingStaff, +): void { + assertAppointmentInBusiness(appointment, staff); + if (appointment.staffId !== staff.id) { + throw new StaffPermissionDeniedError("You can only manage appointments assigned to you."); + } +} + +export interface OwnableNote { + authorStaffId: string; +} + +/** Any staff member may add a note; only its author may edit or delete it — see docs/phase-5-summary.md for the full note permission model and why. */ +export function assertOwnNote(note: OwnableNote, staff: ActingStaff): void { + if (note.authorStaffId !== staff.id) { + throw new StaffPermissionDeniedError("You can only edit or delete your own notes."); + } +} diff --git a/src/features/staff/components/add-stamp-button.tsx b/src/features/staff/components/add-stamp-button.tsx new file mode 100644 index 0000000..4ba5e03 --- /dev/null +++ b/src/features/staff/components/add-stamp-button.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { Stamp } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Modal } from "@/components/ui/modal"; +import { Input } from "@/components/ui/input"; +import { addLoyaltyStampAction } from "../actions/loyalty-actions"; + +export function AddStampButton({ + customerId, + appointmentId, +}: { + customerId: string; + appointmentId?: string; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [amount, setAmount] = useState("1"); + const [reason, setReason] = useState(""); + const [error, setError] = useState(null); + const [result, setResult] = useState<{ rewardCreated?: boolean } | null>(null); + const [isPending, startTransition] = useTransition(); + + const close = () => { + const shouldRefresh = !!result; + setOpen(false); + setResult(null); + setError(null); + setAmount("1"); + setReason(""); + if (shouldRefresh) router.refresh(); + }; + + const submit = () => { + const parsed = Number(amount); + if (!Number.isInteger(parsed) || parsed <= 0) { + setError("Enter a positive number of stamps."); + return; + } + setError(null); + startTransition(async () => { + const res = await addLoyaltyStampAction({ + customerId, + amount: parsed, + appointmentId, + reason: reason || undefined, + }); + if (!res.success) { + setError(res.error ?? "Couldn't add a stamp."); + return; + } + setResult({ rewardCreated: res.rewardCreated }); + }); + }; + + return ( + <> + + + {result ? ( +
+

+ The stamp has been recorded on this customer's loyalty account. +

+ {result.rewardCreated && ( +

+ They've now reached the stamp threshold — a reward is available. +

+ )} + +
+ ) : ( +
+ setAmount(e.target.value)} + /> + setReason(e.target.value)} + /> + {error && ( +

+ {error} +

+ )} + +
+ )} +
+ + ); +} diff --git a/src/features/staff/components/appointment-status-actions.tsx b/src/features/staff/components/appointment-status-actions.tsx new file mode 100644 index 0000000..2382a8c --- /dev/null +++ b/src/features/staff/components/appointment-status-actions.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Modal } from "@/components/ui/modal"; +import { Textarea } from "@/components/ui/textarea"; +import { + cancelStaffAppointmentAction, + checkInAppointmentAction, + completeAppointmentAction, + confirmAppointmentAction, + markNoShowAction, + startAppointmentAction, +} from "../actions/appointment-actions"; + +type RawStatus = + "PENDING" | "CONFIRMED" | "CHECKED_IN" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED" | "NO_SHOW"; +type ConfirmKind = "complete" | "no_show" | "cancel"; + +/** + * The status-appropriate action set — shared by Current Appointment and + * Appointment Details, since both need exactly the same buttons for the + * same status. Every button calls a Server Action that delegates to + * AppointmentService's own state machine; this component never decides + * whether a transition is legal, only which buttons to *offer* for a given + * status (offering an illegal one would just get rejected server-side — + * see appointment-actions.ts). + */ +export function AppointmentStatusActions({ + appointmentId, + status, +}: { + appointmentId: string; + status: RawStatus; +}) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [confirmKind, setConfirmKind] = useState(null); + const [reason, setReason] = useState(""); + const [done, setDone] = useState<{ loyaltyStampAwarded?: boolean } | null>(null); + + const runQuiet = (action: () => Promise<{ success: boolean; error?: string }>) => { + setError(null); + startTransition(async () => { + const result = await action(); + if (!result.success) { + setError(result.error ?? "Something went wrong."); + return; + } + router.refresh(); + }); + }; + + const runConfirmed = () => { + if (!confirmKind) return; + setError(null); + startTransition(async () => { + const result = + confirmKind === "complete" + ? await completeAppointmentAction(appointmentId) + : confirmKind === "no_show" + ? await markNoShowAction(appointmentId) + : await cancelStaffAppointmentAction({ appointmentId, reason: reason || undefined }); + if (!result.success) { + setError(result.error ?? "Something went wrong."); + return; + } + setDone( + confirmKind === "complete" ? { loyaltyStampAwarded: result.loyaltyStampAwarded } : {}, + ); + }); + }; + + const closeConfirm = () => { + const shouldRefresh = !!done; + setConfirmKind(null); + setDone(null); + setReason(""); + setError(null); + if (shouldRefresh) router.refresh(); + }; + + const modalTitle = + confirmKind === "complete" + ? "Complete Appointment?" + : confirmKind === "no_show" + ? "Mark as No-Show?" + : "Cancel Appointment?"; + + return ( +
+ {error && !confirmKind && ( +

+ {error} +

+ )} +
+ {status === "PENDING" && ( + + )} + {status === "CONFIRMED" && ( + + )} + {status === "CHECKED_IN" && ( + + )} + {status === "IN_PROGRESS" && ( + + )} + {status === "CONFIRMED" && ( + + )} + {(status === "PENDING" || status === "CONFIRMED" || status === "CHECKED_IN") && ( + + )} +
+ + + {done ? ( +
+

+ {confirmKind === "complete" && "This appointment is marked complete."} + {confirmKind === "no_show" && "This appointment is marked as a no-show."} + {confirmKind === "cancel" && + "This appointment has been cancelled. It won't be deleted from history."} +

+ {done.loyaltyStampAwarded && ( +

+ A loyalty stamp was added for this visit. +

+ )} + +
+ ) : ( +
+

+ {confirmKind === "complete" && + "This marks the visit as finished and (if eligible) adds a loyalty stamp."} + {confirmKind === "no_show" && + "The customer didn't arrive for this appointment. This can't be undone."} + {confirmKind === "cancel" && + "This can't be undone. You can add an optional reason below."} +

+ {confirmKind === "cancel" && ( +