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" && ( +