Phase 5
Phase 5 is complete. All 53 tasks are done, verified on a fresh database in a single clean run: **121 unit tests**, **55 integration tests**, and **15 E2E journeys** (6 Phase 4 + 9 new Phase 5) all passing, plus a clean `tsc`/`eslint`/`build`. Summary of this final stretch: - **Fixed a real bug**: `StaffService`'s break/time-off update/delete functions threw uncaught instead of failing gracefully on a permission mismatch — fixed with proper `PERMISSION_DENIED` handling, covered by new integration tests. - **Built the remaining Phase 5 UI**: Breaks editor, Time Off editor, Barber Profile, and Notification Preferences — all wired to real services, no placeholders. - **Extended seed data**: more customers, loyalty accounts/transactions/rewards, and appointments spanning every status today (anchored to real time so the "Current Appointment" priority logic is reliably exercised). - **Found and fixed a second real bug live in the browser**: the Break/Time-Off edit sheets showed stale default values instead of the item being edited (a classic `useState`-doesn't-reinitialize-on-prop-change bug) — fixed with a `key` prop to force remount. - **Wrote the full Phase 5 test suite**: unit tests for `selectCurrentAppointment` and all `StaffService` validators, integration tests for `LoyaltyService` and `StaffService` (including the permission-fix regression tests), and 9 new Playwright E2E journeys covering the brief's exact required list. - **Did the desktop-responsive pass**: a persistent sidebar nav replacing the bottom bar at `lg:`, a genuine calendar+"Right Now" side-by-side split, and 2-column layouts for the clients list and customer details — addressing the brief's explicit "more than simple centering" requirement. - **Wrote [docs/phase-5-summary.md](docs/phase-5-summary.md)** documenting everything above in full. Nothing is committed — the whole session (Phases 2–5) sits uncommitted on top of the initial template commit, same as you left it. Let me know if you'd like me to commit, or if you want to move on to Phase 6.
This commit is contained in:
81 files changed
+8024
-113
No files matched your search
@@ -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.
|
||||
@@ -115,3 +115,42 @@ export async function completeRescheduleFlow(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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\/[^/]+$/);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
+312
-1
@@ -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)`,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -163,7 +163,7 @@ export default async function CustomerHomePage() {
|
||||
<AppointmentCard
|
||||
status={STATUS_MAP[upcomingAppointment.status] ?? "confirmed"}
|
||||
serviceName={upcomingAppointment.services.map((s) => s.service.name).join(" + ")}
|
||||
staff={{
|
||||
person={{
|
||||
id: upcomingAppointment.staffId,
|
||||
name: upcomingAppointment.staff.user.name ?? "Barber",
|
||||
}}
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-stone pb-24">
|
||||
{children}
|
||||
<BottomNav items={STAFF_NAV_ITEMS} variant="staff" />
|
||||
</div>
|
||||
);
|
||||
return <StaffShell>{children}</StaffShell>;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-stone pb-24">
|
||||
{children}
|
||||
<BottomNav items={STAFF_NAV_ITEMS} variant="staff" />
|
||||
</div>
|
||||
);
|
||||
return <StaffShell>{children}</StaffShell>;
|
||||
}
|
||||
@@ -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<string, AppointmentStatus> = {
|
||||
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 (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Appointment Details" onBack sticky className="bg-stone" />
|
||||
|
||||
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
|
||||
<Card className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
|
||||
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||||
Ref: {appointment.id.slice(-8)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-line space-y-1 border-t pt-4">
|
||||
<Link
|
||||
href={`/staff/clients/${appointment.customerId}`}
|
||||
className="text-lg font-bold underline"
|
||||
>
|
||||
{appointment.customer.firstName} {appointment.customer.lastName}
|
||||
</Link>
|
||||
<p className="text-muted text-sm">
|
||||
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{appointment.customer.phone && (
|
||||
<a
|
||||
href={`tel:${appointment.customer.phone}`}
|
||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||
>
|
||||
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={`mailto:${appointment.customer.email}`}
|
||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||
>
|
||||
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Services
|
||||
</span>
|
||||
<p className="font-bold">
|
||||
{appointment.services.map((s) => s.service.name).join(" + ")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Date
|
||||
</span>
|
||||
<p className="font-bold">
|
||||
{appointment.startAt.toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: tz,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Time
|
||||
</span>
|
||||
<p className="font-bold">
|
||||
{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,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Duration / Price
|
||||
</span>
|
||||
<p className="font-bold">
|
||||
{totalDuration} min · £{totalPrice.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appointment.notes && (
|
||||
<div className="border-line border-t pt-4">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Booking Notes
|
||||
</span>
|
||||
<p className="text-muted mt-1 text-sm italic">{appointment.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notes.length > 0 && (
|
||||
<div className="border-line border-t pt-4">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Customer Notes
|
||||
</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
{notes.map((n) => (
|
||||
<p key={n.id} className="text-muted text-xs">
|
||||
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loyalty?.isEnabled && (
|
||||
<div className="border-line border-t pt-4">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Loyalty
|
||||
</span>
|
||||
<p className="text-sm font-bold">
|
||||
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
||||
{loyalty.availableReward && (
|
||||
<span className="text-accent-loyalty"> · Reward available</span>
|
||||
)}
|
||||
</p>
|
||||
{loyaltyTransactions.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{loyaltyTransactions.map((t) => (
|
||||
<li key={t.id} className="text-muted text-xs">
|
||||
{t.amount > 0 ? "+" : ""}
|
||||
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} —{" "}
|
||||
{t.type.replace("_", " ").toLowerCase()}
|
||||
{t.reason ? ` (${t.reason})` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appointment.status === "CANCELLED" && (
|
||||
<div className="border-line border-t pt-4 text-sm">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Cancellation
|
||||
</span>
|
||||
<p className="text-muted mt-1">
|
||||
Cancelled {appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: tz })}
|
||||
{cancelledByUser ? ` by ${cancelledByUser.name}` : ""}
|
||||
</p>
|
||||
{appointment.cancellationReason && (
|
||||
<p className="text-muted mt-1 italic">
|
||||
“{appointment.cancellationReason}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a
|
||||
href={`/api/appointments/${appointment.id}/calendar.ics`}
|
||||
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
||||
>
|
||||
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
||||
</a>
|
||||
{RESCHEDULABLE.has(appointment.status) && (
|
||||
<Link
|
||||
href={`/staff/appointments/${appointment.id}/reschedule`}
|
||||
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
|
||||
>
|
||||
Reschedule
|
||||
</Link>
|
||||
)}
|
||||
{appointment.status === "COMPLETED" && (
|
||||
<BookFollowUpButton appointmentId={appointment.id} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AppointmentStatusActions appointmentId={appointment.id} status={appointment.status} />
|
||||
|
||||
{loyalty?.isEnabled && (
|
||||
<AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, AppointmentStatus> = {
|
||||
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 <EmptyState icon={CalendarX2} title={emptyLabel} />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{appointments.map((a) => (
|
||||
<AppointmentCard
|
||||
key={a.id}
|
||||
status={STATUS_MAP[a.status] ?? "confirmed"}
|
||||
serviceName={a.services.map((s) => 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={
|
||||
<Link
|
||||
href={`/staff/appointments/${a.id}`}
|
||||
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Client Details" onBack sticky className="bg-stone" />
|
||||
|
||||
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6 lg:max-w-5xl lg:items-start lg:gap-6 lg:space-y-0 lg:[display:grid] lg:grid-cols-[360px_1fr]">
|
||||
<Card className="space-y-5 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{customer.user?.image ? (
|
||||
<Image
|
||||
src={customer.user.image}
|
||||
alt=""
|
||||
width={56}
|
||||
height={56}
|
||||
className="h-14 w-14 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-stone text-muted flex h-14 w-14 items-center justify-center rounded-full text-lg font-bold">
|
||||
{customer.firstName.charAt(0)}
|
||||
{customer.lastName.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-lg font-bold">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</p>
|
||||
<p className="text-muted text-xs">
|
||||
{customer.isGuest ? "Guest customer" : "Registered customer"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-line space-y-1 border-t pt-4 text-sm">
|
||||
<p className="text-muted">{customer.email}</p>
|
||||
{customer.phone && <p className="text-muted">{customer.phone}</p>}
|
||||
{customer.preferredBarber && (
|
||||
<p className="text-muted">
|
||||
Preferred barber: {customer.preferredBarber.user.name ?? "—"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-line flex flex-wrap gap-3 border-t pt-4">
|
||||
{customer.phone && (
|
||||
<a
|
||||
href={`tel:${customer.phone}`}
|
||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||
>
|
||||
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={`mailto:${customer.email}`}
|
||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||
>
|
||||
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
||||
</a>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/staff/book?customerId=${customer.id}`}>Book Appointment</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loyalty?.isEnabled && loyalty.accountId && (
|
||||
<div className="border-line border-t pt-4">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Loyalty
|
||||
</span>
|
||||
<p className="mb-3 text-sm font-bold">
|
||||
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
||||
{loyalty.availableReward && (
|
||||
<span className="text-accent-loyalty"> · Reward available</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<AddStampButton customerId={id} />
|
||||
<CorrectStampButton loyaltyAccountId={loyalty.accountId} />
|
||||
</div>
|
||||
{loyaltyTransactions.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{loyaltyTransactions.map((t) => (
|
||||
<li key={t.id} className="text-muted text-xs">
|
||||
{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}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-line grid grid-cols-3 gap-3 border-t pt-4 text-center">
|
||||
<div>
|
||||
<p className="text-lg font-bold">{past.length}</p>
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Visits</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold">{upcoming.length}</p>
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Upcoming</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold">{cancelled.length}</p>
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||||
Cancelled
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4 space-y-4 lg:mt-0">
|
||||
<Tabs defaultValue="upcoming">
|
||||
<TabsList>
|
||||
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
|
||||
<TabsTrigger value="past">Past</TabsTrigger>
|
||||
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="upcoming">
|
||||
{renderList(upcoming, "No upcoming appointments")}
|
||||
</TabsContent>
|
||||
<TabsContent value="past">{renderList(past, "No past appointments")}</TabsContent>
|
||||
<TabsContent value="cancelled">
|
||||
{renderList(cancelled, "No cancelled appointments")}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="text-muted mb-4 text-sm font-bold tracking-widest uppercase">Notes</h2>
|
||||
<CustomerNotes customerId={customer.id} notes={notes} actingStaffId={staff.id} />
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <ComingSoon icon={Users} title="Clients" phase="Phase 3: Barber Tools" />;
|
||||
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 (
|
||||
<div className="px-6 pt-12 pb-8 lg:max-w-5xl">
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Clients</h1>
|
||||
|
||||
<form action="/staff/clients" method="GET" className="relative mb-4">
|
||||
{sort && <input type="hidden" name="sort" value={sort} />}
|
||||
<Search
|
||||
className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<label htmlFor="client-search" className="sr-only">
|
||||
Search clients by name, email, or phone
|
||||
</label>
|
||||
<input
|
||||
id="client-search"
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search by name, email, or phone"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="mb-4 flex gap-2">
|
||||
<Link
|
||||
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=name`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
sort !== "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Alphabetical
|
||||
</Link>
|
||||
<Link
|
||||
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=recent`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
sort === "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Recent
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={q ? "No matching clients" : "No clients yet"}
|
||||
description={
|
||||
q
|
||||
? "Try a different name, email, or phone number."
|
||||
: "Clients will show up here once they've booked with you."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{sorted.map((customer) => {
|
||||
const completedVisits = customer.appointments.filter(
|
||||
(a) => a.status === "COMPLETED",
|
||||
).length;
|
||||
const lastVisit = customer.appointments.find((a) => a.status === "COMPLETED");
|
||||
const upcoming = customer.appointments.find(
|
||||
(a) => UPCOMING_STATUSES.includes(a.status) && a.startAt > now,
|
||||
);
|
||||
const loyalty = loyaltySummaries.get(customer.id);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={customer.id}
|
||||
href={`/staff/clients/${customer.id}`}
|
||||
className="rounded-jumbo border-line hover:bg-stone flex items-center gap-3 border bg-white px-5 py-4"
|
||||
>
|
||||
<div className="bg-stone text-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold">
|
||||
{customer.firstName.charAt(0)}
|
||||
{customer.lastName.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</p>
|
||||
<p className="text-muted truncate text-xs">
|
||||
{customer.phone ?? customer.email}
|
||||
{lastVisit &&
|
||||
` · Last visit ${lastVisit.startAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
|
||||
</p>
|
||||
<p className="text-muted mt-0.5 text-[10px] font-bold tracking-widest uppercase">
|
||||
{completedVisits} visit{completedVisits === 1 ? "" : "s"}
|
||||
{upcoming && <span className="text-accent"> · Upcoming</span>}
|
||||
{loyalty && (
|
||||
<span className={loyalty.hasAvailableReward ? "text-accent-loyalty" : ""}>
|
||||
{" "}
|
||||
· {loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <ComingSoon icon={Scissors} title="Current Appointment" phase="Phase 3: Barber Tools" />;
|
||||
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
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 (
|
||||
<div className="px-6 pt-12">
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Current Appointment</h1>
|
||||
<EmptyState
|
||||
icon={Scissors}
|
||||
title="Nothing scheduled right now"
|
||||
description="Your next appointment today will show up here."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4 px-6 pt-12 pb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Current Appointment</h1>
|
||||
|
||||
<Card className="space-y-5 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-lg font-bold">
|
||||
{current.customer.firstName} {current.customer.lastName}
|
||||
</p>
|
||||
<p className="text-muted text-xs">
|
||||
{current.customer.isGuest ? "Guest customer" : "Registered customer"}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill status={STATUS_MAP[current.status] ?? "confirmed"} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{current.customer.phone && (
|
||||
<a
|
||||
href={`tel:${current.customer.phone}`}
|
||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||
>
|
||||
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={`mailto:${current.customer.email}`}
|
||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||
>
|
||||
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
||||
</a>
|
||||
<Link
|
||||
href={`/staff/clients/${current.customerId}`}
|
||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||
>
|
||||
View Customer
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Services
|
||||
</span>
|
||||
<p className="font-bold">{current.services.map((s) => s.service.name).join(" + ")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Time
|
||||
</span>
|
||||
<p className="font-bold">
|
||||
{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,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Duration
|
||||
</span>
|
||||
<p className="font-bold">{totalDuration} min</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Price
|
||||
</span>
|
||||
<p className="font-bold">£{totalPrice.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{current.notes && (
|
||||
<div className="border-line border-t pt-4">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Booking Notes
|
||||
</span>
|
||||
<p className="text-muted mt-1 text-sm italic">{current.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notes.length > 0 && (
|
||||
<div className="border-line border-t pt-4">
|
||||
<span className="text-muted mb-2 flex items-center gap-1.5 text-[10px] font-bold tracking-widest uppercase">
|
||||
<NotebookText className="h-3 w-3" aria-hidden="true" /> Customer Notes
|
||||
</span>
|
||||
<div className="space-y-2">
|
||||
{notes.map((n) => (
|
||||
<p key={n.id} className="text-muted text-xs">
|
||||
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loyalty?.isEnabled && (
|
||||
<div className="border-line border-t pt-4">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Loyalty
|
||||
</span>
|
||||
<p className="text-sm font-bold">
|
||||
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
||||
{loyalty.availableReward && (
|
||||
<span className="text-accent-loyalty"> · Reward available</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastCompleted && (
|
||||
<div className="border-line border-t pt-4">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Last Visit
|
||||
</span>
|
||||
<p className="text-muted text-sm">
|
||||
{lastCompleted.services.map((s) => s.service.name).join(" + ")} ·{" "}
|
||||
{lastCompleted.startAt.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: tz,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<AppointmentStatusActions appointmentId={current.id} status={current.status} />
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{loyalty?.isEnabled && (
|
||||
<AddStampButton customerId={current.customerId} appointmentId={current.id} />
|
||||
)}
|
||||
<BookFollowUpButton appointmentId={current.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <ComingSoon icon={CalendarDays} title="Calendar" phase="Phase 3: Barber Tools" />;
|
||||
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
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 (
|
||||
<div className="pb-6">
|
||||
<div className="flex items-center justify-between px-6 pt-12">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Calendar</h1>
|
||||
<div className="flex gap-2">
|
||||
<BlockTimeButton date={date} />
|
||||
<Button asChild size="sm">
|
||||
<Link href="/staff/book">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
New
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DateStrip
|
||||
selectedDate={date}
|
||||
businessTimezone={staff.business.timezone}
|
||||
basePath="/staff"
|
||||
view={view}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex gap-2 px-6">
|
||||
<Link
|
||||
href={`/staff?date=${date}&view=day`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
view === "day" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Day
|
||||
</Link>
|
||||
<Link
|
||||
href={`/staff?date=${date}&view=agenda`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
view === "agenda" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Agenda
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6">
|
||||
<div className="lg:flex-1">
|
||||
{wholeDayOff ? (
|
||||
<EmptyState
|
||||
icon={CalendarX2}
|
||||
title="You're off this day"
|
||||
description={wholeDayOff.reason ?? "Approved time off."}
|
||||
/>
|
||||
) : view === "day" ? (
|
||||
<DayCalendar
|
||||
appointments={calendarAppointments}
|
||||
blocks={blocks}
|
||||
workingWindow={workingWindow}
|
||||
currentTimeMinutes={currentTimeMinutes}
|
||||
/>
|
||||
) : calendarAppointments.length === 0 ? (
|
||||
<div className="px-6 lg:px-0">
|
||||
<EmptyState
|
||||
icon={CalendarX2}
|
||||
title="No appointments today"
|
||||
description="Your schedule is clear for this day."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 px-6 lg:px-0">
|
||||
{calendarAppointments.map((a) => (
|
||||
<AppointmentCard
|
||||
key={a.id}
|
||||
status={a.status}
|
||||
serviceName={a.serviceNames}
|
||||
person={{ id: a.id, name: a.customerName }}
|
||||
personLabel="for"
|
||||
formattedTime={formatRange(a.startMinutes, a.endMinutes)}
|
||||
hasNotes={a.hasNotes}
|
||||
hasAvailableReward={a.hasAvailableReward}
|
||||
actions={
|
||||
<Link
|
||||
href={`/staff/appointments/${a.id}`}
|
||||
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<aside className="hidden shrink-0 lg:block lg:w-80">
|
||||
<div className="rounded-jumbo border-line sticky top-6 border bg-white p-5">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Right Now
|
||||
</span>
|
||||
{rightNow ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
<AppointmentCard
|
||||
status={STATUS_MAP[rightNow.status] ?? "confirmed"}
|
||||
serviceName={rightNow.services.map((s) => s.service.name).join(" + ")}
|
||||
person={{ id: rightNow.customerId, name: `${rightNow.customer.firstName} ${rightNow.customer.lastName}` }}
|
||||
personLabel="for"
|
||||
formattedTime={formatRange(
|
||||
minutesFromMidnight(rightNow.startAt, staff.business.timezone),
|
||||
minutesFromMidnight(rightNow.endAt, staff.business.timezone),
|
||||
)}
|
||||
/>
|
||||
<Link
|
||||
href={`/staff/appointments/${rightNow.id}`}
|
||||
className="border-line block rounded-lg border px-4 py-2 text-center text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
Open Details
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted mt-3 text-sm">Nothing in progress or up next right now.</p>
|
||||
)}
|
||||
<Link
|
||||
href="/staff/current"
|
||||
className="text-accent mt-4 block text-center text-xs font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Full Current Appointment View
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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)}`;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Breaks" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<BreaksList
|
||||
breaks={breaks.map((b) => ({
|
||||
id: b.id,
|
||||
label: b.label,
|
||||
startMinutes: b.startMinutes,
|
||||
endMinutes: b.endMinutes,
|
||||
days: b.days.map((d) => d.dayOfWeek),
|
||||
}))}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<NotificationPreferencesForm initial={cells} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" className="w-full" disabled>
|
||||
Working Hours, Breaks & Time Off — Phase 3
|
||||
</Button>
|
||||
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
|
||||
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
|
||||
<li key={href}>
|
||||
<Link href={href} className="flex items-center gap-3 px-5 py-4 text-sm font-bold hover:bg-stone">
|
||||
<Icon className="h-5 w-5 text-muted" aria-hidden="true" />
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<SignOutButton />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Profile" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||
<ProfileForm
|
||||
initial={{
|
||||
name: profile.user.name ?? "",
|
||||
phone: profile.user.phone ?? "",
|
||||
image: profile.user.image ?? "",
|
||||
bio: profile.bio ?? "",
|
||||
isBookableOnline: profile.isBookableOnline,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">
|
||||
Services You Offer
|
||||
</p>
|
||||
{profile.services.length === 0 ? (
|
||||
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
|
||||
No services assigned yet. Ask the owner to add you to a service.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
|
||||
{profile.services.map((s) => (
|
||||
<li key={s.service.id} className="px-5 py-3 text-sm font-bold">
|
||||
{s.service.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-muted mt-2 text-xs">
|
||||
Only the business owner can change which services you offer.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Time Off" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<TimeOffList upcoming={toEditable(upcoming)} past={toEditable(past)} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Weekday, string>;
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Working Hours" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<WorkingHoursForm initialRows={workingHours} businessHoursLabel={businessHoursLabel} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ServiceSelector
|
||||
businessId={business.id}
|
||||
services={services}
|
||||
categoryNames={Object.fromEntries(categoryNames)}
|
||||
categoryOrder={orderedCategoryIds}
|
||||
initialSelectedServiceIds={draft.serviceIds}
|
||||
rebooking={!!draft.rebookFromAppointmentId}
|
||||
onContinue={handleContinue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 <div className="bg-stone min-h-screen">{children}</div>;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="pb-6">
|
||||
<TopNav title="Reschedule" onBack sticky className="bg-stone" />
|
||||
|
||||
<Card className="mx-6 mt-4 flex items-center gap-3 p-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold">
|
||||
{appointment.services.map((s) => s.service.name).join(" + ")} for{" "}
|
||||
{appointment.customer.firstName} {appointment.customer.lastName}
|
||||
</p>
|
||||
<p className="text-muted text-xs">
|
||||
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,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4">
|
||||
<BarberDateTimePicker
|
||||
businessId={staff.businessId}
|
||||
businessTimezone={staff.business.timezone}
|
||||
serviceIds={appointment.services.map((s) => s.serviceId)}
|
||||
initialStaffId={appointment.staffId}
|
||||
onContinue={handleContinue}
|
||||
continueLabel="Confirm Reschedule"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="pb-6">
|
||||
<TopNav title="Barber & Time" onBack sticky className="bg-stone" />
|
||||
<BarberDateTimePicker
|
||||
businessId={staff.businessId}
|
||||
businessTimezone={staff.business.timezone}
|
||||
serviceIds={draft.serviceIds}
|
||||
initialStaffId={draft.staffId}
|
||||
initialDate={draft.date}
|
||||
onContinue={handleContinue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <div className="bg-stone min-h-screen">{children}</div>;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="pb-6">
|
||||
<TopNav title="Book Appointment" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
|
||||
<Card className="p-6">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Booking For
|
||||
</span>
|
||||
<p className="mt-1 text-lg font-bold">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</p>
|
||||
<p className="text-muted text-sm">{customer.phone ?? customer.email}</p>
|
||||
</Card>
|
||||
<form action={selectAction}>
|
||||
<Button type="submit" className="w-full">
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="pb-6">
|
||||
<TopNav title="Book Appointment" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-bold">Find an Existing Customer</h2>
|
||||
<form action="/staff/book" method="GET" className="relative mb-3">
|
||||
<Search
|
||||
className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<label htmlFor="customer-search" className="sr-only">
|
||||
Search customers
|
||||
</label>
|
||||
<input
|
||||
id="customer-search"
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search by name, email, or phone"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</form>
|
||||
|
||||
{q && (
|
||||
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
|
||||
{results.length === 0 ? (
|
||||
<p className="text-muted px-5 py-4 text-sm">
|
||||
No customers match “{q}”.
|
||||
</p>
|
||||
) : (
|
||||
results.map((customer) => {
|
||||
const action = selectExistingCustomerAction.bind(null, customer.id);
|
||||
return (
|
||||
<form key={customer.id} action={action}>
|
||||
<button
|
||||
type="submit"
|
||||
className="hover:bg-stone flex w-full items-center gap-3 px-5 py-4 text-left"
|
||||
>
|
||||
<div className="bg-stone text-muted flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-bold">
|
||||
{customer.firstName.charAt(0)}
|
||||
{customer.lastName.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</p>
|
||||
<p className="text-muted text-xs">{customer.phone ?? customer.email}</p>
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<UserPlus className="text-accent h-4 w-4" aria-hidden="true" /> Or Add a New Customer
|
||||
</h2>
|
||||
<Card className="p-5">
|
||||
<NewGuestCustomerForm />
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Review Booking" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
|
||||
<Card className="space-y-6 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Customer
|
||||
</span>
|
||||
<p className="text-sm font-bold">{customerName}</p>
|
||||
<p className="text-muted text-xs">{customerContact}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/staff/book"
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Change
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="border-line space-y-4 border-t pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Services
|
||||
</span>
|
||||
<ul className="mt-1 text-sm font-bold">
|
||||
{services.map((s) => (
|
||||
<li key={s.id}>{s.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Link
|
||||
href="/staff/book/services"
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Barber
|
||||
</span>
|
||||
<p className="text-sm font-bold">
|
||||
{staffMember?.user.name ?? "Any available barber"}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/staff/book/barber-time"
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Date & Time
|
||||
</span>
|
||||
<p className="text-sm font-bold">
|
||||
{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,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/staff/book/barber-time"
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-line grid grid-cols-2 gap-6 border-t pt-6">
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Duration
|
||||
</span>
|
||||
<p className="text-sm font-bold">{totalDuration} min</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Total
|
||||
</span>
|
||||
<p className="text-sm font-bold">£{totalPrice.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ConfirmStaffBooking />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ServiceSelector
|
||||
services={services}
|
||||
categoryNames={categoryNames}
|
||||
categoryOrder={categoryOrder}
|
||||
initialSelectedServiceIds={draft.serviceIds}
|
||||
rebooking={!!draft.followUpFromAppointmentId}
|
||||
onContinue={handleContinue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export default function StyleGuidePage() {
|
||||
<AppointmentCard
|
||||
status="confirmed"
|
||||
serviceName="Standard Cut"
|
||||
staff={{
|
||||
person={{
|
||||
id: "1",
|
||||
name: "Marcus Reed",
|
||||
avatarUrl: "https://storage.googleapis.com/uxpilot-auth.appspot.com/avatars/avatar-2.jpg",
|
||||
|
||||
@@ -15,7 +15,7 @@ import { cn } from "@/lib/utils/cn";
|
||||
* to Client Components from Server Components"). A string key sidesteps
|
||||
* that entirely.
|
||||
*/
|
||||
const ICON_MAP = {
|
||||
export const NAV_ICON_MAP = {
|
||||
house: House,
|
||||
"calendar-check": CalendarCheck,
|
||||
user: User,
|
||||
@@ -25,7 +25,7 @@ const ICON_MAP = {
|
||||
settings: Settings,
|
||||
} satisfies Record<string, LucideIcon>;
|
||||
|
||||
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. */}
|
||||
<div className="mx-auto flex w-full max-w-2xl items-center justify-between">
|
||||
{items.map(({ href, label, icon }) => {
|
||||
const Icon = ICON_MAP[icon];
|
||||
const Icon = NAV_ICON_MAP[icon];
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
import { StaffSidebarNav } from "./staff-sidebar-nav";
|
||||
import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
|
||||
|
||||
/** Shared by (staff) and (owner) — see docs/implementation-plan.md §3: owner is a permission on a staff account, not a separate shell. */
|
||||
export function StaffShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone pb-24 lg:pb-0 lg:pl-56">
|
||||
<StaffSidebarNav items={STAFF_NAV_ITEMS} />
|
||||
{children}
|
||||
<BottomNav items={STAFF_NAV_ITEMS} variant="staff" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className="fixed inset-y-0 left-0 z-50 hidden w-56 flex-col border-r border-white/5 bg-[#0c0c0c] px-4 py-6 lg:flex"
|
||||
>
|
||||
<div className="mb-8 flex items-center gap-2 px-2 text-white">
|
||||
<Scissors className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-sm font-bold tracking-widest uppercase">NextChair</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map(({ href, label, icon }) => {
|
||||
const Icon = NAV_ICON_MAP[icon];
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-bold transition-colors",
|
||||
active ? "bg-white/10 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<StatusPill status={status} />
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold">{formattedDate}</p>
|
||||
<p className="text-xs text-muted">{formattedTime}</p>
|
||||
{formattedDate && <p className="text-sm font-bold">{formattedDate}</p>}
|
||||
<p className="text-muted text-xs">{formattedTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
{staff.avatarUrl ? (
|
||||
{person.avatarUrl ? (
|
||||
<Image
|
||||
src={staff.avatarUrl}
|
||||
src={person.avatarUrl}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-stone text-xs font-bold text-muted">
|
||||
{staff.name.charAt(0)}
|
||||
<div className="bg-stone text-muted flex h-10 w-10 items-center justify-center rounded-full text-xs font-bold">
|
||||
{person.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold">{serviceName}</h3>
|
||||
<p className="text-xs text-muted">with {staff.name}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="truncate text-sm font-bold">{serviceName}</h3>
|
||||
{hasNotes && (
|
||||
<NotebookText className="text-muted h-3.5 w-3.5 shrink-0" aria-label="Has notes" />
|
||||
)}
|
||||
{hasAvailableReward && (
|
||||
<Gift
|
||||
className="text-accent-loyalty h-3.5 w-3.5 shrink-0"
|
||||
aria-label="Reward available"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted text-xs">
|
||||
{personLabel} {person.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex gap-2 border-t border-line pt-4">{actions}</div>}
|
||||
{actions && <div className="border-line flex gap-2 border-t pt-4">{actions}</div>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<AppointmentStatus, { label: string; className: string }> = {
|
||||
/**
|
||||
* 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 (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded-full px-3 py-1 text-[10px] font-bold tracking-widest uppercase",
|
||||
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-[10px] font-bold tracking-widest uppercase",
|
||||
config.className,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
{iconOnly ? <span className="sr-only">{config.label}</span> : config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
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<string[]>(initialSelectedServiceIds);
|
||||
const [error, setError] = useState<string | null>(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.");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<StaffAppointmentActionResult> {
|
||||
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<StaffAppointmentActionResult> {
|
||||
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<StaffAppointmentActionResult> {
|
||||
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<StaffAppointmentActionResult> {
|
||||
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<StaffAppointmentActionResult> {
|
||||
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<StaffAppointmentActionResult> {
|
||||
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<StaffAppointmentActionResult> {
|
||||
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.";
|
||||
}
|
||||
@@ -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<BlockTimeActionResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<BreakActionResult> {
|
||||
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<BreakActionResult> {
|
||||
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<BreakActionResult> {
|
||||
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." };
|
||||
}
|
||||
@@ -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<CustomerNoteActionResult> {
|
||||
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<CustomerNoteActionResult> {
|
||||
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<CustomerNoteActionResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<LoyaltyActionResult> {
|
||||
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<LoyaltyActionResult> {
|
||||
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.",
|
||||
};
|
||||
}
|
||||
@@ -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<ProfileActionResult> {
|
||||
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<ProfileActionResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
export async function selectExistingCustomerAction(customerId: string): Promise<void> {
|
||||
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<StaffBookingStepResult> {
|
||||
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<StaffBookingStepResult> {
|
||||
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<StaffBookingStepResult> {
|
||||
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<StaffBookingStepResult> {
|
||||
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<StaffBookingStepResult> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
@@ -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<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
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<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
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<TimeOffActionResult> {
|
||||
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." };
|
||||
}
|
||||
@@ -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<WorkingHoursActionResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
||||
<Stamp className="h-4 w-4" aria-hidden="true" />
|
||||
Add Loyalty Stamp
|
||||
</Button>
|
||||
<Modal open={open} onClose={close} title={result ? "Stamp Added" : "Add Loyalty Stamp"}>
|
||||
{result ? (
|
||||
<div className="space-y-4">
|
||||
<p role="status" className="text-muted text-sm">
|
||||
The stamp has been recorded on this customer's loyalty account.
|
||||
</p>
|
||||
{result.rewardCreated && (
|
||||
<p className="border-accent-loyalty/30 bg-accent-loyalty/10 rounded-lg border p-3 text-xs font-medium">
|
||||
They've now reached the stamp threshold — a reward is available.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" onClick={close}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Stamps to add"
|
||||
type="number"
|
||||
min={1}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Reason (optional)"
|
||||
placeholder="e.g. Referral bonus"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={submit}>
|
||||
Add Stamp
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [confirmKind, setConfirmKind] = useState<ConfirmKind | null>(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 (
|
||||
<div className="space-y-3">
|
||||
{error && !confirmKind && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{status === "PENDING" && (
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isPending}
|
||||
onClick={() => runQuiet(() => confirmAppointmentAction(appointmentId))}
|
||||
>
|
||||
Confirm Booking
|
||||
</Button>
|
||||
)}
|
||||
{status === "CONFIRMED" && (
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isPending}
|
||||
onClick={() => runQuiet(() => checkInAppointmentAction(appointmentId))}
|
||||
>
|
||||
Check In
|
||||
</Button>
|
||||
)}
|
||||
{status === "CHECKED_IN" && (
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isPending}
|
||||
onClick={() => runQuiet(() => startAppointmentAction(appointmentId))}
|
||||
>
|
||||
Start Appointment
|
||||
</Button>
|
||||
)}
|
||||
{status === "IN_PROGRESS" && (
|
||||
<Button size="sm" onClick={() => setConfirmKind("complete")}>
|
||||
Complete Appointment
|
||||
</Button>
|
||||
)}
|
||||
{status === "CONFIRMED" && (
|
||||
<Button size="sm" variant="secondary" onClick={() => setConfirmKind("no_show")}>
|
||||
Mark No-Show
|
||||
</Button>
|
||||
)}
|
||||
{(status === "PENDING" || status === "CONFIRMED" || status === "CHECKED_IN") && (
|
||||
<Button size="sm" variant="destructive" onClick={() => setConfirmKind("cancel")}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal open={!!confirmKind} onClose={closeConfirm} title={done ? "Done" : modalTitle}>
|
||||
{done ? (
|
||||
<div className="space-y-4">
|
||||
<p role="status" className="text-muted text-sm">
|
||||
{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."}
|
||||
</p>
|
||||
{done.loyaltyStampAwarded && (
|
||||
<p className="border-accent-loyalty/30 bg-accent-loyalty/10 rounded-lg border p-3 text-xs font-medium">
|
||||
A loyalty stamp was added for this visit.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" onClick={closeConfirm}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted text-sm">
|
||||
{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."}
|
||||
</p>
|
||||
{confirmKind === "cancel" && (
|
||||
<Textarea
|
||||
placeholder="Reason (optional)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={closeConfirm}>
|
||||
Keep As Is
|
||||
</Button>
|
||||
<Button
|
||||
variant={confirmKind === "complete" ? "primary" : "destructive"}
|
||||
className="flex-1"
|
||||
isLoading={isPending}
|
||||
onClick={runConfirmed}
|
||||
>
|
||||
{confirmKind === "complete"
|
||||
? "Complete"
|
||||
: confirmKind === "no_show"
|
||||
? "Mark No-Show"
|
||||
: "Cancel It"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarOff } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BottomSheet } from "@/components/ui/bottom-sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { BlockTimeConflict } from "../actions/block-time";
|
||||
import { blockTimeAction } from "../actions/block-time";
|
||||
|
||||
function toMinutes(hhmm: string): number | null {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
||||
if (!match) return null;
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
export function BlockTimeButton({ date }: { date: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [startTime, setStartTime] = useState("12:00");
|
||||
const [endTime, setEndTime] = useState("13:00");
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conflicts, setConflicts] = useState<BlockTimeConflict[] | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setError(null);
|
||||
setConflicts(null);
|
||||
setReason("");
|
||||
};
|
||||
|
||||
const submit = (acknowledgeConflicts: boolean) => {
|
||||
const startMinutes = toMinutes(startTime);
|
||||
const endMinutes = toMinutes(endTime);
|
||||
if (startMinutes === null || endMinutes === null || startMinutes >= endMinutes) {
|
||||
setError("Start time must be before end time.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await blockTimeAction({
|
||||
date,
|
||||
startMinutes,
|
||||
endMinutes,
|
||||
reason,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
setConflicts(result.conflicts ?? null);
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setConflicts(null);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
||||
<CalendarOff className="h-4 w-4" aria-hidden="true" />
|
||||
Block Time
|
||||
</Button>
|
||||
<BottomSheet open={open} onClose={handleClose} title="Block Time">
|
||||
<div className="space-y-4 pb-2">
|
||||
<p className="text-muted text-sm">
|
||||
Reserve part of{" "}
|
||||
{new Date(`${date}T00:00:00`).toLocaleDateString("en-GB", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}{" "}
|
||||
for something other than appointments — a meeting, admin, a personal break.
|
||||
</p>
|
||||
|
||||
{conflicts && conflicts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
This overlaps {conflicts.length} existing appointment
|
||||
{conflicts.length > 1 ? "s" : ""}:
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{conflicts.map((c) => (
|
||||
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
|
||||
<Link href={`/staff/appointments/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">{c.serviceNames}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
isLoading={isPending}
|
||||
onClick={() => submit(true)}
|
||||
>
|
||||
Block Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Start"
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="End"
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Reason (optional)"
|
||||
placeholder="e.g. Team meeting"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
|
||||
Block This Time
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { startStaffFollowUpAction } from "../actions/staff-booking";
|
||||
|
||||
export function BookFollowUpButton({ appointmentId }: { appointmentId: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
|
||||
isLoading={isPending}
|
||||
onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))}
|
||||
>
|
||||
Book Follow-Up
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BottomSheet } from "@/components/ui/bottom-sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import type { BreakActionResult, BreakConflictItem } from "../actions/breaks";
|
||||
import { createBreakAction, deleteBreakAction, updateBreakAction } from "../actions/breaks";
|
||||
|
||||
const DAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
Weekday.SUNDAY,
|
||||
];
|
||||
const DAY_SHORT: Record<Weekday, string> = {
|
||||
MONDAY: "Mon",
|
||||
TUESDAY: "Tue",
|
||||
WEDNESDAY: "Wed",
|
||||
THURSDAY: "Thu",
|
||||
FRIDAY: "Fri",
|
||||
SATURDAY: "Sat",
|
||||
SUNDAY: "Sun",
|
||||
};
|
||||
|
||||
function toMinutes(hhmm: string): number | null {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
||||
if (!match) return null;
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
function toTimeString(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export interface EditableBreak {
|
||||
id: string;
|
||||
label: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
days: Weekday[];
|
||||
}
|
||||
|
||||
export function BreakFormSheet({
|
||||
open,
|
||||
onClose,
|
||||
existing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
existing?: EditableBreak | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [label, setLabel] = useState(existing?.label ?? "Break");
|
||||
const [startTime, setStartTime] = useState(existing ? toTimeString(existing.startMinutes) : "12:00");
|
||||
const [endTime, setEndTime] = useState(existing ? toTimeString(existing.endMinutes) : "13:00");
|
||||
const [days, setDays] = useState<Weekday[]>(existing?.days ?? []);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conflicts, setConflicts] = useState<BreakConflictItem[] | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isDeleting, startDeleteTransition] = useTransition();
|
||||
|
||||
const reset = () => {
|
||||
setLabel(existing?.label ?? "Break");
|
||||
setStartTime(existing ? toTimeString(existing.startMinutes) : "12:00");
|
||||
setEndTime(existing ? toTimeString(existing.endMinutes) : "13:00");
|
||||
setDays(existing?.days ?? []);
|
||||
setError(null);
|
||||
setConflicts(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
reset();
|
||||
};
|
||||
|
||||
const toggleDay = (day: Weekday) => {
|
||||
setDays((prev) => (prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day]));
|
||||
};
|
||||
|
||||
const submit = (acknowledgeConflicts: boolean) => {
|
||||
const startMinutes = toMinutes(startTime);
|
||||
const endMinutes = toMinutes(endTime);
|
||||
if (startMinutes === null || endMinutes === null || startMinutes >= endMinutes) {
|
||||
setError("Start time must be before end time.");
|
||||
return;
|
||||
}
|
||||
if (days.length === 0) {
|
||||
setError("Select at least one day.");
|
||||
return;
|
||||
}
|
||||
if (!label.trim()) {
|
||||
setError("Give this break a label.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const input = { label, startMinutes, endMinutes, days };
|
||||
const result: BreakActionResult = existing
|
||||
? await updateBreakAction(existing.id, input, acknowledgeConflicts)
|
||||
: await createBreakAction(input, acknowledgeConflicts);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
setConflicts(result.conflicts ?? null);
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!existing) return;
|
||||
startDeleteTransition(async () => {
|
||||
const result = await deleteBreakAction(existing.id);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't delete this break.");
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheet open={open} onClose={handleClose} title={existing ? "Edit Break" : "New Break"}>
|
||||
<div className="space-y-4 pb-2">
|
||||
{conflicts && conflicts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
This overlaps {conflicts.length} upcoming appointment
|
||||
{conflicts.length > 1 ? "s" : ""}:
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{conflicts.map((c) => (
|
||||
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
|
||||
<Link href={`/staff/appointments/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">{c.serviceNames}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
isLoading={isPending}
|
||||
onClick={() => submit(true)}
|
||||
>
|
||||
Save Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input label="Label" placeholder="e.g. Lunch" value={label} onChange={(e) => setLabel(e.target.value)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Start" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
|
||||
<Input label="End" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Repeats on</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAY_ORDER.map((day) => {
|
||||
const active = days.includes(day);
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => toggleDay(day)}
|
||||
aria-pressed={active}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-bold transition-colors ${
|
||||
active ? "border-accent bg-accent text-accent-foreground" : "border-line bg-white text-ink"
|
||||
}`}
|
||||
>
|
||||
{DAY_SHORT[day]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
|
||||
{existing ? "Save Changes" : "Add Break"}
|
||||
</Button>
|
||||
{existing && (
|
||||
<Button variant="ghost" className="w-full text-danger" isLoading={isDeleting} onClick={handleDelete}>
|
||||
Delete Break
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { BreakFormSheet } from "./break-form-sheet";
|
||||
import type { EditableBreak } from "./break-form-sheet";
|
||||
|
||||
const DAY_SHORT: Record<Weekday, string> = {
|
||||
MONDAY: "Mon",
|
||||
TUESDAY: "Tue",
|
||||
WEDNESDAY: "Wed",
|
||||
THURSDAY: "Thu",
|
||||
FRIDAY: "Fri",
|
||||
SATURDAY: "Sat",
|
||||
SUNDAY: "Sun",
|
||||
};
|
||||
const DAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
Weekday.SUNDAY,
|
||||
];
|
||||
|
||||
function formatDays(days: Weekday[]): string {
|
||||
return DAY_ORDER.filter((d) => days.includes(d))
|
||||
.map((d) => DAY_SHORT[d])
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function toTimeString(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
const period = h >= 12 ? "PM" : "AM";
|
||||
const h12 = h % 12 === 0 ? 12 : h % 12;
|
||||
return `${h12}:${String(m).padStart(2, "0")} ${period}`;
|
||||
}
|
||||
|
||||
export function BreaksList({ breaks }: { breaks: EditableBreak[] }) {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<EditableBreak | null>(null);
|
||||
|
||||
const openNew = () => {
|
||||
setEditing(null);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
const openEdit = (b: EditableBreak) => {
|
||||
setEditing(b);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="secondary" className="w-full" onClick={openNew}>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
Add Break
|
||||
</Button>
|
||||
|
||||
{breaks.length === 0 ? (
|
||||
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
|
||||
No recurring breaks yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
|
||||
{breaks.map((b) => (
|
||||
<li key={b.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(b)}
|
||||
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left hover:bg-stone"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-bold">{b.label}</p>
|
||||
<p className="text-xs text-muted">
|
||||
{toTimeString(b.startMinutes)}–{toTimeString(b.endMinutes)} · {formatDays(b.days)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<BreakFormSheet
|
||||
key={editing?.id ?? "new"}
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
existing={editing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { confirmStaffBookingAction } from "../actions/staff-booking";
|
||||
|
||||
export function ConfirmStaffBooking() {
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleConfirm = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await confirmStaffBookingAction(notes || undefined);
|
||||
if (!result.success) setError(result.error ?? "Something went wrong. Please try again.");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
label="Booking Notes (optional)"
|
||||
placeholder="e.g. requested style, allergies…"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleConfirm} isLoading={isPending}>
|
||||
Confirm Booking
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { Undo2 } 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 { Textarea } from "@/components/ui/textarea";
|
||||
import { correctLoyaltyStampAction } from "../actions/loyalty-actions";
|
||||
|
||||
/** Removes stamps via a compensating ledger entry — never edits or deletes the original transaction. A reason is required (enforced both here and server-side in LoyaltyService.correctStamp). */
|
||||
export function CorrectStampButton({ loyaltyAccountId }: { loyaltyAccountId: string }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [amount, setAmount] = useState("1");
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const close = () => {
|
||||
const shouldRefresh = done;
|
||||
setOpen(false);
|
||||
setDone(false);
|
||||
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 to remove.");
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
setError("A reason is required to correct a stamp.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await correctLoyaltyStampAction({ loyaltyAccountId, amount: parsed, reason });
|
||||
if (!res.success) {
|
||||
setError(res.error ?? "Couldn't correct that stamp.");
|
||||
return;
|
||||
}
|
||||
setDone(true);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
||||
<Undo2 className="h-4 w-4" aria-hidden="true" />
|
||||
Correct Stamp
|
||||
</Button>
|
||||
<Modal open={open} onClose={close} title={done ? "Stamp Corrected" : "Correct a Stamp"}>
|
||||
{done ? (
|
||||
<div className="space-y-4">
|
||||
<p role="status" className="text-muted text-sm">
|
||||
A compensating entry has been recorded — the original stamp stays in the
|
||||
customer's history.
|
||||
</p>
|
||||
<Button className="w-full" onClick={close}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted text-sm">
|
||||
This removes stamps by recording a correction — it never deletes the original entry.
|
||||
</p>
|
||||
<Input
|
||||
label="Stamps to remove"
|
||||
type="number"
|
||||
min={1}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Reason (required)"
|
||||
placeholder="e.g. Added by mistake"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" variant="destructive" isLoading={isPending} onClick={submit}>
|
||||
Correct Stamp
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
addCustomerNoteAction,
|
||||
deleteCustomerNoteAction,
|
||||
updateCustomerNoteAction,
|
||||
} from "../actions/customer-notes";
|
||||
|
||||
export interface CustomerNoteItem {
|
||||
id: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authorStaffId: string;
|
||||
authorName: string;
|
||||
}
|
||||
|
||||
export function CustomerNotes({
|
||||
customerId,
|
||||
notes,
|
||||
actingStaffId,
|
||||
}: {
|
||||
customerId: string;
|
||||
notes: CustomerNoteItem[];
|
||||
actingStaffId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [newBody, setNewBody] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editBody, setEditBody] = useState("");
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const submitNew = () => {
|
||||
if (!newBody.trim()) return;
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await addCustomerNoteAction({ customerId, body: newBody });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't add note.");
|
||||
return;
|
||||
}
|
||||
setNewBody("");
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (note: CustomerNoteItem) => {
|
||||
setEditingId(note.id);
|
||||
setEditBody(note.body);
|
||||
};
|
||||
|
||||
const saveEdit = (noteId: string) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await updateCustomerNoteAction({ noteId, body: editBody });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save note.");
|
||||
return;
|
||||
}
|
||||
setEditingId(null);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDelete = (noteId: string) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await deleteCustomerNoteAction({ noteId });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't delete note.");
|
||||
return;
|
||||
}
|
||||
setConfirmDeleteId(null);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
placeholder="Add a note about this customer…"
|
||||
value={newBody}
|
||||
onChange={(e) => setNewBody(e.target.value)}
|
||||
/>
|
||||
<Button size="sm" isLoading={isPending} disabled={!newBody.trim()} onClick={submitNew}>
|
||||
Add Note
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<p className="text-muted text-sm">No notes yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{notes.map((note) => {
|
||||
const isOwn = note.authorStaffId === actingStaffId;
|
||||
const isEditing = editingId === note.id;
|
||||
const edited = note.updatedAt !== note.createdAt;
|
||||
|
||||
return (
|
||||
<li key={note.id} className="border-line rounded-lg border bg-white p-3">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||||
{note.authorName} ·{" "}
|
||||
{new Date(note.createdAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}
|
||||
{edited && " (edited)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea value={editBody} onChange={(e) => setEditBody(e.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditingId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" isLoading={isPending} onClick={() => saveEdit(note.id)}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted text-sm">{note.body}</p>
|
||||
{isOwn && (
|
||||
<div className="mt-2 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEdit(note)}
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{confirmDeleteId === note.id ? (
|
||||
<>
|
||||
<span className="text-muted text-[10px]">Delete this note?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => confirmDelete(note.id)}
|
||||
className="text-danger text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
className="text-muted text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDeleteId(note.id)}
|
||||
className="text-danger text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { addDays, todayIn } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
/**
|
||||
* Today/previous/next/date-selection, all as plain navigation — every
|
||||
* control here is a Link that changes the `?date=` search param, so the
|
||||
* whole calendar page stays a Server Component with no client JS needed
|
||||
* just to pick a day.
|
||||
*/
|
||||
export function DateStrip({
|
||||
selectedDate,
|
||||
businessTimezone,
|
||||
basePath,
|
||||
view,
|
||||
}: {
|
||||
selectedDate: CalendarDate;
|
||||
businessTimezone: string;
|
||||
basePath: string;
|
||||
view?: string;
|
||||
}) {
|
||||
const today = todayIn(businessTimezone);
|
||||
const viewParam = view ? `&view=${view}` : "";
|
||||
const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}`;
|
||||
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(selectedDate, i - 3));
|
||||
const label = new Date(`${selectedDate}T00:00:00`).toLocaleDateString("en-GB", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3 px-6 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href={hrefFor(addDays(selectedDate, -1))}
|
||||
aria-label="Previous day"
|
||||
className="border-line flex h-9 w-9 items-center justify-center rounded-full border bg-white text-white"
|
||||
>
|
||||
<ChevronLeft className="text-ink h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-bold">{selectedDate === today ? "Today" : label}</p>
|
||||
{selectedDate === today && <p className="text-muted text-xs">{label}</p>}
|
||||
{selectedDate !== today && (
|
||||
<Link
|
||||
href={hrefFor(today)}
|
||||
className="text-accent text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Jump to today
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={hrefFor(addDays(selectedDate, 1))}
|
||||
aria-label="Next day"
|
||||
className="border-line flex h-9 w-9 items-center justify-center rounded-full border bg-white"
|
||||
>
|
||||
<ChevronRight className="text-ink h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-1">
|
||||
{days.map((date) => {
|
||||
const isSelected = date === selectedDate;
|
||||
const isToday = date === today;
|
||||
const d = new Date(`${date}T00:00:00`);
|
||||
return (
|
||||
<Link
|
||||
key={date}
|
||||
href={hrefFor(date)}
|
||||
aria-current={isSelected ? "date" : undefined}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center gap-1 rounded-xl border py-2 text-xs",
|
||||
isSelected
|
||||
? "border-ink bg-ink text-white"
|
||||
: "border-line text-ink hover:bg-stone bg-white",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[9px] font-bold tracking-widest uppercase",
|
||||
isSelected ? "text-white/70" : "text-muted",
|
||||
)}
|
||||
>
|
||||
{d.toLocaleDateString("en-GB", { weekday: "short" })}
|
||||
</span>
|
||||
<span className={cn("font-bold", isToday && !isSelected && "text-accent")}>
|
||||
{d.getDate()}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import Link from "next/link";
|
||||
import { Gift, NotebookText } from "lucide-react";
|
||||
import { StatusPill } from "@/components/ui/status-pill";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
const START_HOUR = 7;
|
||||
const END_HOUR = 21;
|
||||
const PX_PER_HOUR = 64;
|
||||
const RANGE_START_MINUTES = START_HOUR * 60;
|
||||
const RANGE_END_MINUTES = END_HOUR * 60;
|
||||
|
||||
function topFor(minutes: number): number {
|
||||
return ((clampToRange(minutes) - RANGE_START_MINUTES) / 60) * PX_PER_HOUR;
|
||||
}
|
||||
function clampToRange(minutes: number): number {
|
||||
return Math.min(RANGE_END_MINUTES, Math.max(RANGE_START_MINUTES, minutes));
|
||||
}
|
||||
function heightFor(startMinutes: number, endMinutes: number): number {
|
||||
return Math.max(20, topFor(endMinutes) - topFor(startMinutes));
|
||||
}
|
||||
|
||||
export interface CalendarAppointment {
|
||||
id: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
status: AppointmentStatus;
|
||||
hasNotes: boolean;
|
||||
hasAvailableReward: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarBlock {
|
||||
id: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
label: string;
|
||||
kind: "break" | "timeOff";
|
||||
}
|
||||
|
||||
/**
|
||||
* A single day's grid for one staff member. Deliberately no drag-and-drop —
|
||||
* every interactive element is a plain Link (open details / start a
|
||||
* reschedule from there), per the brief's preference for a reliable
|
||||
* tap-to-act flow over an unreliable drag interaction. Pure presentation:
|
||||
* every minute value here is already computed by the page from real
|
||||
* WorkingHours/Break/TimeOff/Appointment rows, nothing is derived here.
|
||||
*/
|
||||
export function DayCalendar({
|
||||
appointments,
|
||||
blocks,
|
||||
workingWindow,
|
||||
currentTimeMinutes,
|
||||
}: {
|
||||
appointments: CalendarAppointment[];
|
||||
blocks: CalendarBlock[];
|
||||
/** The staff member's working window that day, for shading outside-hours space — null if they're off entirely. */
|
||||
workingWindow: { startMinutes: number; endMinutes: number } | null;
|
||||
/** Minutes-from-midnight right now, only passed when the viewed day is today. */
|
||||
currentTimeMinutes: number | null;
|
||||
}) {
|
||||
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
|
||||
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto px-6 pb-8">
|
||||
<div className="relative ml-14" style={{ height: gridHeight }}>
|
||||
{/* Hour gridlines + labels */}
|
||||
{hourMarks.map((hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="border-line absolute inset-x-0 border-t"
|
||||
style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}
|
||||
>
|
||||
<span className="text-muted absolute -top-2.5 -left-14 w-12 text-right text-[10px] font-medium">
|
||||
{hour === 12 ? "12pm" : hour > 12 ? `${hour - 12}pm` : `${hour}am`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Outside-working-hours shading */}
|
||||
{workingWindow ? (
|
||||
<>
|
||||
{workingWindow.startMinutes > RANGE_START_MINUTES && (
|
||||
<div
|
||||
className="bg-line/20 absolute inset-x-0"
|
||||
style={{ top: 0, height: topFor(workingWindow.startMinutes) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{workingWindow.endMinutes < RANGE_END_MINUTES && (
|
||||
<div
|
||||
className="bg-line/20 absolute inset-x-0"
|
||||
style={{ top: topFor(workingWindow.endMinutes), bottom: 0 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-line/20 absolute inset-0" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
{/* Breaks / time off */}
|
||||
{blocks.map((block) => (
|
||||
<div
|
||||
key={block.id}
|
||||
className="border-warning/40 bg-warning/10 absolute inset-x-1 z-10 overflow-hidden rounded-lg border bg-[repeating-linear-gradient(135deg,transparent,transparent_6px,rgba(0,0,0,0.04)_6px,rgba(0,0,0,0.04)_12px)] px-2 py-1"
|
||||
style={{
|
||||
top: topFor(block.startMinutes),
|
||||
height: heightFor(block.startMinutes, block.endMinutes),
|
||||
}}
|
||||
>
|
||||
<p className="text-warning-foreground truncate text-[10px] font-bold tracking-widest uppercase">
|
||||
{block.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Appointments */}
|
||||
{appointments.map((appt) => (
|
||||
<Link
|
||||
key={appt.id}
|
||||
href={`/staff/appointments/${appt.id}`}
|
||||
className={cn(
|
||||
"absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 bg-white p-2 shadow-sm transition-shadow hover:shadow-md",
|
||||
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
|
||||
statusBorderClass(appt.status),
|
||||
)}
|
||||
style={{
|
||||
top: topFor(appt.startMinutes),
|
||||
height: heightFor(appt.startMinutes, appt.endMinutes),
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<StatusPill status={appt.status} iconOnly className="px-1.5 py-0.5" />
|
||||
{appt.hasNotes && (
|
||||
<NotebookText className="text-muted h-3 w-3 shrink-0" aria-label="Has notes" />
|
||||
)}
|
||||
{appt.hasAvailableReward && (
|
||||
<Gift
|
||||
className="text-accent-loyalty h-3 w-3 shrink-0"
|
||||
aria-label="Reward available"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs font-bold">{appt.customerName}</p>
|
||||
<p className="text-muted truncate text-[10px]">{appt.serviceNames}</p>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Current-time indicator */}
|
||||
{currentTimeMinutes !== null &&
|
||||
currentTimeMinutes >= RANGE_START_MINUTES &&
|
||||
currentTimeMinutes <= RANGE_END_MINUTES && (
|
||||
<div
|
||||
className="border-danger absolute inset-x-0 z-30 border-t-2"
|
||||
style={{ top: topFor(currentTimeMinutes) }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="bg-danger absolute -top-1.5 -left-1.5 h-3 w-3 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusBorderClass(status: AppointmentStatus): string {
|
||||
switch (status) {
|
||||
case "cancelled":
|
||||
case "no_show":
|
||||
return "border-status-cancelled opacity-60";
|
||||
case "completed":
|
||||
return "border-line opacity-70";
|
||||
case "in_progress":
|
||||
return "border-status-progress";
|
||||
case "arrived":
|
||||
return "border-status-confirmed";
|
||||
case "pending_approval":
|
||||
return "border-status-pending";
|
||||
default:
|
||||
return "border-status-confirmed";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { guestDetailsSchema } from "@/lib/booking-draft/schema";
|
||||
import type { GuestDetails } from "@/lib/booking-draft/schema";
|
||||
import { selectNewGuestCustomerAction } from "../actions/staff-booking";
|
||||
|
||||
/** Same guestDetailsSchema the customer app's own guest checkout uses — a new customer entered by staff is resolved through the exact same CustomerService matching rules, see staff-booking.ts. */
|
||||
export function NewGuestCustomerForm() {
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<GuestDetails>({ resolver: zodResolver(guestDetailsSchema) });
|
||||
|
||||
const onSubmit = (data: GuestDetails) => {
|
||||
setServerError(null);
|
||||
startTransition(async () => {
|
||||
const result = await selectNewGuestCustomerAction(data);
|
||||
if (!result.success) {
|
||||
setServerError(result.error ?? "Please check the highlighted fields.");
|
||||
if (result.fieldErrors) {
|
||||
for (const [field, messages] of Object.entries(result.fieldErrors)) {
|
||||
if (messages?.[0]) setError(field as keyof GuestDetails, { message: messages[0] });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="First Name" error={errors.firstName?.message} {...register("firstName")} />
|
||||
<Input label="Last Name" error={errors.lastName?.message} {...register("lastName")} />
|
||||
</div>
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
<Input label="Phone Number" type="tel" error={errors.phone?.message} {...register("phone")} />
|
||||
{serverError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{serverError}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" isLoading={isPending}>
|
||||
Continue With This Customer
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NotificationCategory, NotificationChannel } from "@/generated/prisma/enums";
|
||||
import { saveNotificationPreferencesAction } from "../actions/profile";
|
||||
|
||||
const CATEGORY_LABEL: Record<NotificationCategory, string> = {
|
||||
BOOKING_CONFIRMATION: "New booking confirmed",
|
||||
BOOKING_REMINDER: "Upcoming appointment reminders",
|
||||
BOOKING_CHANGED: "Appointment rescheduled",
|
||||
BOOKING_CANCELLED: "Appointment cancelled",
|
||||
LOYALTY_REWARD_EARNED: "Customer earns a loyalty stamp",
|
||||
LOYALTY_REWARD_AVAILABLE: "Customer reward becomes available",
|
||||
MARKETING: "Product news and tips",
|
||||
};
|
||||
const CATEGORY_ORDER: NotificationCategory[] = [
|
||||
NotificationCategory.BOOKING_CONFIRMATION,
|
||||
NotificationCategory.BOOKING_REMINDER,
|
||||
NotificationCategory.BOOKING_CHANGED,
|
||||
NotificationCategory.BOOKING_CANCELLED,
|
||||
NotificationCategory.LOYALTY_REWARD_EARNED,
|
||||
NotificationCategory.LOYALTY_REWARD_AVAILABLE,
|
||||
NotificationCategory.MARKETING,
|
||||
];
|
||||
const CHANNEL_ORDER: NotificationChannel[] = [
|
||||
NotificationChannel.EMAIL,
|
||||
NotificationChannel.SMS,
|
||||
NotificationChannel.PUSH,
|
||||
];
|
||||
const CHANNEL_LABEL: Record<NotificationChannel, string> = { EMAIL: "Email", SMS: "SMS", PUSH: "Push" };
|
||||
|
||||
export interface PreferenceCellState {
|
||||
category: NotificationCategory;
|
||||
channel: NotificationChannel;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export function NotificationPreferencesForm({ initial }: { initial: PreferenceCellState[] }) {
|
||||
const router = useRouter();
|
||||
const [cells, setCells] = useState(initial);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const isEnabled = (category: NotificationCategory, channel: NotificationChannel) =>
|
||||
cells.find((c) => c.category === category && c.channel === channel)?.isEnabled ?? true;
|
||||
|
||||
const toggle = (category: NotificationCategory, channel: NotificationChannel) => {
|
||||
setSaved(false);
|
||||
setCells((prev) =>
|
||||
prev.map((c) => (c.category === category && c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
startTransition(async () => {
|
||||
await saveNotificationPreferencesAction(cells);
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted text-sm">
|
||||
Choose which of these you want to hear about, and how. This controls what you receive —
|
||||
delivery isn't wired up yet.
|
||||
</p>
|
||||
|
||||
<div className="overflow-hidden rounded-jumbo border border-line bg-white">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line">
|
||||
<th scope="col" className="px-4 py-3 font-bold">
|
||||
Notification
|
||||
</th>
|
||||
{CHANNEL_ORDER.map((channel) => (
|
||||
<th key={channel} scope="col" className="px-2 py-3 text-center text-xs font-bold">
|
||||
{CHANNEL_LABEL[channel]}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CATEGORY_ORDER.map((category) => (
|
||||
<tr key={category} className="border-b border-line last:border-b-0">
|
||||
<th scope="row" className="px-4 py-3 text-xs font-medium">
|
||||
{CATEGORY_LABEL[category]}
|
||||
</th>
|
||||
{CHANNEL_ORDER.map((channel) => (
|
||||
<td key={channel} className="px-2 py-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isEnabled(category, channel)}
|
||||
aria-label={`${CATEGORY_LABEL[category]} via ${CHANNEL_LABEL[channel]}`}
|
||||
onClick={() => toggle(category, channel)}
|
||||
className={`h-5 w-5 rounded-md border transition-colors ${
|
||||
isEnabled(category, channel) ? "border-accent bg-accent" : "border-line bg-white"
|
||||
}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{saved && (
|
||||
<p role="status" className="text-accent text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { updateProfileAction } from "../actions/profile";
|
||||
|
||||
export interface ProfileFormInitial {
|
||||
name: string;
|
||||
phone: string;
|
||||
image: string;
|
||||
bio: string;
|
||||
isBookableOnline: boolean;
|
||||
}
|
||||
|
||||
export function ProfileForm({ initial }: { initial: ProfileFormInitial }) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(initial.name);
|
||||
const [phone, setPhone] = useState(initial.phone);
|
||||
const [image, setImage] = useState(initial.image);
|
||||
const [bio, setBio] = useState(initial.bio);
|
||||
const [isBookableOnline, setIsBookableOnline] = useState(initial.isBookableOnline);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
startTransition(async () => {
|
||||
const result = await updateProfileAction({ name, phone, image, bio, isBookableOnline });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save your profile.");
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Input label="Display name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input
|
||||
label="Phone"
|
||||
type="tel"
|
||||
placeholder="+1 555 123 4567"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Photo URL"
|
||||
placeholder="https://…"
|
||||
value={image}
|
||||
onChange={(e) => setImage(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Bio"
|
||||
placeholder="A short introduction customers will see on your profile"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="rounded-jumbo border border-line bg-white p-5">
|
||||
<Switch
|
||||
label="Visible for online booking"
|
||||
description="Customers can find and book you when this is on."
|
||||
checked={isBookableOnline}
|
||||
onCheckedChange={setIsBookableOnline}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BottomSheet } from "@/components/ui/bottom-sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { TimeOffActionResult, TimeOffConflictItem } from "../actions/time-off";
|
||||
import { createTimeOffAction, deleteTimeOffAction, updateTimeOffAction } from "../actions/time-off";
|
||||
|
||||
export interface EditableTimeOff {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function TimeOffFormSheet({
|
||||
open,
|
||||
onClose,
|
||||
existing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
existing?: EditableTimeOff | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [startDate, setStartDate] = useState(existing?.startDate ?? todayIso());
|
||||
const [endDate, setEndDate] = useState(existing?.endDate ?? existing?.startDate ?? todayIso());
|
||||
const [reason, setReason] = useState(existing?.reason ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conflicts, setConflicts] = useState<TimeOffConflictItem[] | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isDeleting, startDeleteTransition] = useTransition();
|
||||
|
||||
const reset = () => {
|
||||
setStartDate(existing?.startDate ?? todayIso());
|
||||
setEndDate(existing?.endDate ?? existing?.startDate ?? todayIso());
|
||||
setReason(existing?.reason ?? "");
|
||||
setError(null);
|
||||
setConflicts(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
reset();
|
||||
};
|
||||
|
||||
const submit = (acknowledgeConflicts: boolean) => {
|
||||
if (startDate > endDate) {
|
||||
setError("Start date must be on or before the end date.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const input = { startDate, endDate, reason: reason.trim() || undefined };
|
||||
const result: TimeOffActionResult = existing
|
||||
? await updateTimeOffAction(existing.id, input, acknowledgeConflicts)
|
||||
: await createTimeOffAction(input, acknowledgeConflicts);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
setConflicts(result.conflicts ?? null);
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!existing) return;
|
||||
startDeleteTransition(async () => {
|
||||
const result = await deleteTimeOffAction(existing.id);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't remove this time off.");
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheet open={open} onClose={handleClose} title={existing ? "Edit Time Off" : "New Time Off"}>
|
||||
<div className="space-y-4 pb-2">
|
||||
{conflicts && conflicts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
This overlaps {conflicts.length} existing appointment
|
||||
{conflicts.length > 1 ? "s" : ""}. These won't be cancelled automatically —
|
||||
you'll need to reschedule or cancel them yourself.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{conflicts.map((c) => (
|
||||
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
|
||||
<Link href={`/staff/appointments/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">{c.serviceNames}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
isLoading={isPending}
|
||||
onClick={() => submit(true)}
|
||||
>
|
||||
Save Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Start date" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||
<Input label="End date" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||
</div>
|
||||
<Input
|
||||
label="Reason (optional)"
|
||||
placeholder="e.g. Vacation"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
|
||||
{existing ? "Save Changes" : "Add Time Off"}
|
||||
</Button>
|
||||
{existing && (
|
||||
<Button variant="ghost" className="w-full text-danger" isLoading={isDeleting} onClick={handleDelete}>
|
||||
Remove Time Off
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { EditableTimeOff } from "./time-off-form-sheet";
|
||||
import { TimeOffFormSheet } from "./time-off-form-sheet";
|
||||
|
||||
function formatRange(startDate: string, endDate: string): string {
|
||||
const start = new Date(`${startDate}T00:00:00`);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const startLabel = start.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
|
||||
if (startDate === endDate) return startLabel;
|
||||
const endLabel = end.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
|
||||
return `${startLabel} – ${endLabel}`;
|
||||
}
|
||||
|
||||
export function TimeOffList({
|
||||
upcoming,
|
||||
past,
|
||||
}: {
|
||||
upcoming: EditableTimeOff[];
|
||||
past: EditableTimeOff[];
|
||||
}) {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<EditableTimeOff | null>(null);
|
||||
|
||||
const openNew = () => {
|
||||
setEditing(null);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
const openEdit = (t: EditableTimeOff) => {
|
||||
setEditing(t);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="secondary" className="w-full" onClick={openNew}>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
Add Time Off
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Upcoming</p>
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
|
||||
No upcoming time off.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
|
||||
{upcoming.map((t) => (
|
||||
<li key={t.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(t)}
|
||||
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left hover:bg-stone"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-bold">{formatRange(t.startDate, t.endDate)}</p>
|
||||
{t.reason && <p className="text-xs text-muted">{t.reason}</p>}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{past.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Past</p>
|
||||
<ul className="divide-y divide-line rounded-jumbo border border-line bg-stone/50">
|
||||
{past.map((t) => (
|
||||
<li key={t.id} className="px-5 py-4">
|
||||
<p className="text-sm font-bold text-muted">{formatRange(t.startDate, t.endDate)}</p>
|
||||
{t.reason && <p className="text-xs text-muted">{t.reason}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TimeOffFormSheet
|
||||
key={editing?.id ?? "new"}
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
existing={editing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { saveWorkingHoursAction } from "../actions/working-hours";
|
||||
|
||||
const DAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
Weekday.SUNDAY,
|
||||
];
|
||||
const DAY_LABEL: Record<Weekday, string> = {
|
||||
MONDAY: "Monday",
|
||||
TUESDAY: "Tuesday",
|
||||
WEDNESDAY: "Wednesday",
|
||||
THURSDAY: "Thursday",
|
||||
FRIDAY: "Friday",
|
||||
SATURDAY: "Saturday",
|
||||
SUNDAY: "Sunday",
|
||||
};
|
||||
|
||||
interface DayState {
|
||||
dayOfWeek: Weekday;
|
||||
isOff: boolean;
|
||||
startTime: string; // "HH:MM"
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
function toTimeString(minutes: number | null): string {
|
||||
if (minutes === null) return "09:00";
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toMinutes(time: string): number {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
return (h ?? 0) * 60 + (m ?? 0);
|
||||
}
|
||||
|
||||
export interface InitialWorkingHoursRow {
|
||||
dayOfWeek: Weekday;
|
||||
isOff: boolean;
|
||||
startMinutes: number | null;
|
||||
endMinutes: number | null;
|
||||
}
|
||||
|
||||
export function WorkingHoursForm({ initialRows, businessHoursLabel }: { initialRows: InitialWorkingHoursRow[]; businessHoursLabel: Record<Weekday, string> }) {
|
||||
const router = useRouter();
|
||||
const [days, setDays] = useState<DayState[]>(() =>
|
||||
DAY_ORDER.map((dayOfWeek) => {
|
||||
const existing = initialRows.find((r) => r.dayOfWeek === dayOfWeek);
|
||||
return {
|
||||
dayOfWeek,
|
||||
isOff: existing?.isOff ?? true,
|
||||
startTime: toTimeString(existing?.startMinutes ?? null),
|
||||
endTime: toTimeString(existing?.endMinutes ?? 19 * 60),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateDay = (dayOfWeek: Weekday, patch: Partial<DayState>) => {
|
||||
setSaved(false);
|
||||
setDays((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
|
||||
};
|
||||
|
||||
const copyMondayToAll = () => {
|
||||
const monday = days.find((d) => d.dayOfWeek === Weekday.MONDAY);
|
||||
if (!monday) return;
|
||||
setSaved(false);
|
||||
setDays((prev) => prev.map((d) => (d.dayOfWeek === Weekday.MONDAY ? d : { ...d, isOff: monday.isOff, startTime: monday.startTime, endTime: monday.endTime })));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await saveWorkingHoursAction(
|
||||
days.map((d) => ({
|
||||
dayOfWeek: d.dayOfWeek,
|
||||
isOff: d.isOff,
|
||||
...(d.isOff ? {} : { startMinutes: toMinutes(d.startTime), endMinutes: toMinutes(d.endTime) }),
|
||||
})),
|
||||
);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save working hours.");
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button type="button" onClick={copyMondayToAll} className="text-xs font-bold uppercase tracking-widest text-accent underline">
|
||||
Copy Monday to all days
|
||||
</button>
|
||||
|
||||
<div className="divide-y divide-line rounded-jumbo border border-line bg-white">
|
||||
{days.map((day) => (
|
||||
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={!day.isOff} onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isOff: !checked })} aria-label={`${DAY_LABEL[day.dayOfWeek]} working`} />
|
||||
<div>
|
||||
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
|
||||
<p className="text-[10px] text-muted">Business hours: {businessHoursLabel[day.dayOfWeek]}</p>
|
||||
</div>
|
||||
</div>
|
||||
{day.isOff ? (
|
||||
<span className="text-xs text-muted">Off</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
value={day.startTime}
|
||||
onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })}
|
||||
className="rounded-lg border border-line px-2 py-1.5 text-xs"
|
||||
aria-label={`${DAY_LABEL[day.dayOfWeek]} start time`}
|
||||
/>
|
||||
<span className="text-xs text-muted">to</span>
|
||||
<input
|
||||
type="time"
|
||||
value={day.endTime}
|
||||
onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })}
|
||||
className="rounded-lg border border-line px-2 py-1.5 text-xs"
|
||||
aria-label={`${DAY_LABEL[day.dayOfWeek]} end time`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-sm font-medium text-accent">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { bookingDraftSchema, emptyDraft } from "./schema";
|
||||
import type { BookingDraft } from "./schema";
|
||||
import { bookingDraftSchema, emptyDraft, emptyStaffDraft, staffBookingDraftSchema } from "./schema";
|
||||
import type { BookingDraft, StaffBookingDraft } from "./schema";
|
||||
import { sign, verify } from "./sign";
|
||||
|
||||
/**
|
||||
@@ -80,3 +80,44 @@ export async function readLastBookingReference(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff "book for customer" draft ─────────────────────────────────────
|
||||
// A separate cookie from the customer draft above — see the schema
|
||||
// comment on StaffBookingDraft for why.
|
||||
|
||||
const STAFF_COOKIE_NAME = "nc_staff_booking_draft";
|
||||
|
||||
export async function readStaffDraft(businessId: string): Promise<StaffBookingDraft> {
|
||||
const store = await cookies();
|
||||
const raw = store.get(STAFF_COOKIE_NAME)?.value;
|
||||
if (!raw) return emptyStaffDraft(businessId);
|
||||
|
||||
const payload = verify(raw);
|
||||
if (!payload) return emptyStaffDraft(businessId);
|
||||
|
||||
try {
|
||||
const parsed = staffBookingDraftSchema.safeParse(JSON.parse(payload));
|
||||
if (!parsed.success || parsed.data.businessId !== businessId)
|
||||
return emptyStaffDraft(businessId);
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return emptyStaffDraft(businessId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeStaffDraft(draft: StaffBookingDraft): Promise<void> {
|
||||
const store = await cookies();
|
||||
const token = sign(JSON.stringify(draft));
|
||||
store.set(STAFF_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearStaffDraft(): Promise<void> {
|
||||
const store = await cookies();
|
||||
store.delete(STAFF_COOKIE_NAME);
|
||||
}
|
||||
@@ -36,3 +36,34 @@ export function emptyDraft(businessId: string): BookingDraft {
|
||||
policyAcknowledged: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The staff-facing "book for customer" draft (src/app/staff/book) — a
|
||||
* deliberately separate shape and cookie from the customer one above
|
||||
* (see cookie.ts), even though the two overlap heavily, so a staff member
|
||||
* booking on a customer's behalf can never collide with their own
|
||||
* in-progress personal booking in another tab. Reuses `guestDetailsSchema`
|
||||
* as-is for "staff is entering a brand-new customer's details" — the same
|
||||
* shape CustomerService.resolveCustomerForBooking already knows how to
|
||||
* resolve/match, so no new matching logic is needed for that path.
|
||||
*/
|
||||
export const staffBookingDraftSchema = z.object({
|
||||
businessId: z.string(),
|
||||
serviceIds: z.array(z.string()).default([]),
|
||||
/** undefined = "any available barber". */
|
||||
staffId: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
startAt: z.iso.datetime().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
/** An existing CustomerProfile the staff member picked from search. */
|
||||
customerId: z.string().optional(),
|
||||
/** A brand-new guest customer the staff member is entering inline. Ignored if customerId is set. */
|
||||
guest: guestDetailsSchema.optional(),
|
||||
/** Set by "Book Follow-Up" so the services step can show which appointment this draft originated from — display only, never trusted for pricing. */
|
||||
followUpFromAppointmentId: z.string().optional(),
|
||||
});
|
||||
export type StaffBookingDraft = z.infer<typeof staffBookingDraftSchema>;
|
||||
|
||||
export function emptyStaffDraft(businessId: string): StaffBookingDraft {
|
||||
return { businessId, serviceIds: [] };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cache } from "react";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
/**
|
||||
* The signed-in staff member's own profile, request-deduped via React's
|
||||
* `cache()` the same way getPrimaryBusiness() is — every staff page needs
|
||||
* this, and several call it more than once per render (page + nested
|
||||
* layout-level checks), so this avoids repeating the query.
|
||||
*
|
||||
* Returns null for a guest or a customer-only account; (staff)/layout.tsx
|
||||
* already redirects those away, but every page re-checks anyway per the
|
||||
* "permission enforcement happens again at each layer" rule established in
|
||||
* Phase 2 (see src/lib/auth/permissions.ts).
|
||||
*/
|
||||
export const getActingStaff = cache(async () => {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return null;
|
||||
|
||||
return prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
|
||||
business: { select: { id: true, name: true, timezone: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export type ActingStaffProfile = NonNullable<Awaited<ReturnType<typeof getActingStaff>>>;
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Generic version of the {valid, issues} shape scheduling/types.ts
|
||||
* pioneered in Phase 3 — new Phase 5 services (Staff, Loyalty) want the
|
||||
* same "typed error code + human message, never throw for an expected
|
||||
* business-rule failure" pattern but with their own closed code unions.
|
||||
* scheduling/types.ts itself is left untouched (its BookingValidationErrorCode
|
||||
* union is already relied on across Phases 3-4) rather than widened to
|
||||
* cover unrelated domains.
|
||||
*/
|
||||
export interface ValidationIssue<Code extends string> {
|
||||
code: Code;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult<Code extends string> {
|
||||
valid: boolean;
|
||||
issues: ValidationIssue<Code>[];
|
||||
}
|
||||
|
||||
export function ok<Code extends string>(): ValidationResult<Code> {
|
||||
return { valid: true, issues: [] };
|
||||
}
|
||||
|
||||
export function fail<Code extends string>(
|
||||
...issues: ValidationIssue<Code>[]
|
||||
): ValidationResult<Code> {
|
||||
return { valid: false, issues };
|
||||
}
|
||||
|
||||
export function mergeValidation<Code extends string>(
|
||||
...results: ValidationResult<Code>[]
|
||||
): ValidationResult<Code> {
|
||||
const issues = results.flatMap((r) => r.issues);
|
||||
return { valid: issues.length === 0, issues };
|
||||
}
|
||||
@@ -69,7 +69,10 @@ export async function resolveCustomerForBooking(
|
||||
throw new Error("resolveCustomerForBooking: either userId or guest details are required.");
|
||||
}
|
||||
|
||||
async function resolveAuthenticatedCustomer(prisma: PrismaClient, userId: string): Promise<ResolvedCustomer> {
|
||||
async function resolveAuthenticatedCustomer(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
): Promise<ResolvedCustomer> {
|
||||
const existing = await prisma.customerProfile.findUnique({ where: { userId } });
|
||||
if (existing) return { customerId: existing.id, isNewProfile: false };
|
||||
|
||||
@@ -89,7 +92,10 @@ async function resolveAuthenticatedCustomer(prisma: PrismaClient, userId: string
|
||||
return { customerId: created.id, isNewProfile: true };
|
||||
}
|
||||
|
||||
async function resolveGuestCustomer(prisma: PrismaClient, guest: GuestDetails): Promise<ResolvedCustomer> {
|
||||
async function resolveGuestCustomer(
|
||||
prisma: PrismaClient,
|
||||
guest: GuestDetails,
|
||||
): Promise<ResolvedCustomer> {
|
||||
// Case-insensitive email matching without Prisma's `mode: "insensitive"`
|
||||
// filter — that option is Postgres/MongoDB-only, not MySQL, which would
|
||||
// break this query silently (or fail) after a provider swap. Normalizing
|
||||
@@ -118,3 +124,28 @@ async function resolveGuestCustomer(prisma: PrismaClient, guest: GuestDetails):
|
||||
});
|
||||
return { customerId: created.id, isNewProfile: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-directory search (src/app/(staff)/staff/clients). Deliberately a
|
||||
* plain in-memory filter, not a Prisma `contains`/`mode: "insensitive"`
|
||||
* query — that Prisma option is Postgres/Mongo-only (same portability
|
||||
* reasoning as resolveGuestCustomer's email matching above), and a
|
||||
* single-business shop's customer list is small enough that filtering
|
||||
* application-side is simpler than a portable case-insensitive SQL query.
|
||||
*/
|
||||
export function normalizeSearchQuery(query: string): string {
|
||||
return query.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function matchesCustomerSearch(
|
||||
customer: { firstName: string; lastName: string; email: string; phone: string | null },
|
||||
query: string,
|
||||
): boolean {
|
||||
const normalized = normalizeSearchQuery(query);
|
||||
if (!normalized) return true;
|
||||
return (
|
||||
`${customer.firstName} ${customer.lastName}`.toLowerCase().includes(normalized) ||
|
||||
customer.email.toLowerCase().includes(normalized) ||
|
||||
(customer.phone ?? "").toLowerCase().includes(normalized)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { RewardType } from "@/generated/prisma/enums";
|
||||
import {
|
||||
addStamp,
|
||||
awardStampForCompletedAppointment,
|
||||
correctStamp,
|
||||
getLoyaltyHistory,
|
||||
getLoyaltySummary,
|
||||
} from "../loyalty.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
/**
|
||||
* Covers Phase 5's ledger-discipline requirements against a real database:
|
||||
* no duplicate auto-stamp per completed appointment, no reward stacking,
|
||||
* eligibility rules, and the correction path's clamp + required reason.
|
||||
*/
|
||||
describe("LoyaltyService (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
async function enableLoyalty(overrides: { stampsRequired?: number } = {}) {
|
||||
return prisma.loyaltyProgramme.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
isEnabled: true,
|
||||
stampsRequired: overrides.stampsRequired ?? 5,
|
||||
rewardType: RewardType.PERCENTAGE,
|
||||
rewardValue: 50,
|
||||
rewardLabel: "50% Off",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function completedAppointment(staffId: string, customerId: string) {
|
||||
return prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "COMPLETED",
|
||||
startAt: new Date(),
|
||||
endAt: new Date(Date.now() + 30 * 60_000),
|
||||
services: {
|
||||
create: [
|
||||
{
|
||||
serviceId: business.haircutServiceId,
|
||||
priceAtBooking: 20,
|
||||
durationAtBookingMinutes: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.loyaltyTransaction.deleteMany({
|
||||
where: { loyaltyAccount: { loyaltyProgramme: { businessId: business.businessId } } },
|
||||
}).catch(() => {});
|
||||
await prisma.loyaltyReward.deleteMany({
|
||||
where: { loyaltyAccount: { loyaltyProgramme: { businessId: business.businessId } } },
|
||||
}).catch(() => {});
|
||||
await prisma.loyaltyAccount.deleteMany({
|
||||
where: { loyaltyProgramme: { businessId: business.businessId } },
|
||||
}).catch(() => {});
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
it("awards exactly one stamp for a completed appointment, even if called twice", async () => {
|
||||
await enableLoyalty();
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const appointment = await completedAppointment(staffId, customerId);
|
||||
|
||||
const first = await awardStampForCompletedAppointment(prisma, {
|
||||
appointmentId: appointment.id,
|
||||
staffId,
|
||||
});
|
||||
expect(first.success).toBe(true);
|
||||
expect(first.alreadyAwarded).toBeUndefined();
|
||||
|
||||
const second = await awardStampForCompletedAppointment(prisma, {
|
||||
appointmentId: appointment.id,
|
||||
staffId,
|
||||
});
|
||||
expect(second.success).toBe(true);
|
||||
expect(second.alreadyAwarded).toBe(true);
|
||||
|
||||
const summary = await getLoyaltySummary(prisma, { customerId, businessId: business.businessId });
|
||||
expect(summary?.currentStamps).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses to award a stamp for a non-completed appointment", async () => {
|
||||
await enableLoyalty();
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const appointment = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: new Date(),
|
||||
endAt: new Date(Date.now() + 30 * 60_000),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.validation.issues[0]?.code).toBe("APPOINTMENT_NOT_COMPLETED");
|
||||
});
|
||||
|
||||
it("creates exactly one available reward when the threshold is crossed, never stacking a second", async () => {
|
||||
const programme = await enableLoyalty({ stampsRequired: 3 });
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
const firstAdd = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 3, staffId });
|
||||
expect(firstAdd.rewardCreated).toBe(true);
|
||||
|
||||
const secondAdd = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
|
||||
expect(secondAdd.rewardCreated).toBe(false); // already has an AVAILABLE reward — no stacking
|
||||
|
||||
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
|
||||
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
|
||||
});
|
||||
const availableRewards = await prisma.loyaltyReward.count({
|
||||
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
|
||||
});
|
||||
expect(availableRewards).toBe(1);
|
||||
expect(account.currentStamps).toBe(5);
|
||||
});
|
||||
|
||||
it("rejects a manual stamp when loyalty is disabled for the business", async () => {
|
||||
// No enableLoyalty() call — business has no loyalty programme at all.
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
const result = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 1, staffId });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.validation.issues[0]?.code).toBe("LOYALTY_DISABLED");
|
||||
});
|
||||
|
||||
describe("eligibility", () => {
|
||||
it("skips the stamp when the appointment's staff member isn't in an eligible-staff list", async () => {
|
||||
const programme = await enableLoyalty();
|
||||
const { staffId: eligibleStaffId } = await createTestStaff(business);
|
||||
const { staffId: ineligibleStaffId } = await createTestStaff(business);
|
||||
await prisma.loyaltyEligibleStaff.create({
|
||||
data: { loyaltyProgrammeId: programme.id, staffId: eligibleStaffId },
|
||||
});
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const appointment = await completedAppointment(ineligibleStaffId, customerId);
|
||||
|
||||
const result = await awardStampForCompletedAppointment(prisma, {
|
||||
appointmentId: appointment.id,
|
||||
staffId: ineligibleStaffId,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.validation.issues[0]?.code).toBe("NOT_ELIGIBLE_STAFF");
|
||||
});
|
||||
|
||||
it("skips the stamp when none of the appointment's services are eligible", async () => {
|
||||
const programme = await enableLoyalty();
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
// Only the beard trim is eligible; the fixture appointment books a haircut.
|
||||
await prisma.loyaltyEligibleService.create({
|
||||
data: { loyaltyProgrammeId: programme.id, serviceId: business.beardTrimServiceId },
|
||||
});
|
||||
const appointment = await completedAppointment(staffId, customerId);
|
||||
|
||||
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.validation.issues[0]?.code).toBe("NOT_ELIGIBLE_SERVICE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("correctStamp", () => {
|
||||
it("requires a reason", async () => {
|
||||
const programme = await enableLoyalty();
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
|
||||
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
|
||||
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
|
||||
});
|
||||
|
||||
const result = await correctStamp(prisma, {
|
||||
loyaltyAccountId: account.id,
|
||||
amount: 1,
|
||||
staffId,
|
||||
reason: " ",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.validation.issues[0]?.code).toBe("REASON_REQUIRED");
|
||||
});
|
||||
|
||||
it("clamps the removal at the current balance instead of going negative", async () => {
|
||||
const programme = await enableLoyalty();
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
|
||||
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
|
||||
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
|
||||
});
|
||||
|
||||
const result = await correctStamp(prisma, {
|
||||
loyaltyAccountId: account.id,
|
||||
amount: 10, // more than the current balance of 2
|
||||
staffId,
|
||||
reason: "Accidental double stamp",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const updated = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
|
||||
expect(updated.currentStamps).toBe(0);
|
||||
|
||||
const history = await getLoyaltyHistory(prisma, { loyaltyAccountId: account.id });
|
||||
const correction = history.find((t) => t.type === "STAMP_REMOVED");
|
||||
expect(correction?.amount).toBe(-2); // ledger records what actually happened, not the requested 10
|
||||
expect(correction?.reason).toBe("Accidental double stamp");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client";
|
||||
import { fail, ok } from "@/lib/validation-result";
|
||||
import type { ValidationResult } from "@/lib/validation-result";
|
||||
|
||||
/**
|
||||
* Ledger-based loyalty, per the brief: the cached `LoyaltyAccount.currentStamps`
|
||||
* is always derived from — and only ever changed alongside — a
|
||||
* `LoyaltyTransaction` row. Nothing in this file writes `currentStamps`
|
||||
* without creating the matching transaction in the same call, and nothing
|
||||
* outside this file should touch either table directly.
|
||||
*
|
||||
* Reward reset-on-redemption (see LoyaltyReward's own schema comment) is
|
||||
* deliberately not implemented here — redeeming a reward isn't one of the
|
||||
* Phase 5 barber capabilities, so `currentStamps` is allowed to sit at or
|
||||
* above `stampsRequired` once a reward becomes available. Redemption is a
|
||||
* documented Phase 5 limitation, not an oversight.
|
||||
*/
|
||||
|
||||
export type LoyaltyErrorCode =
|
||||
| "LOYALTY_DISABLED"
|
||||
| "PROGRAMME_NOT_FOUND"
|
||||
| "NOT_ELIGIBLE_SERVICE"
|
||||
| "NOT_ELIGIBLE_STAFF"
|
||||
| "REASON_REQUIRED"
|
||||
| "INVALID_AMOUNT"
|
||||
| "APPOINTMENT_NOT_FOUND"
|
||||
| "APPOINTMENT_NOT_COMPLETED";
|
||||
|
||||
export interface LoyaltySummary {
|
||||
isEnabled: boolean;
|
||||
accountId: string | null;
|
||||
currentStamps: number;
|
||||
stampsRequired: number;
|
||||
availableReward: { id: string; label: string; createdAt: Date } | null;
|
||||
rewardLabel: string | null;
|
||||
stampIcon: string | null;
|
||||
cardBackgroundColor: string | null;
|
||||
}
|
||||
|
||||
/** The customer's loyalty standing for this business — null only if no loyalty programme has ever been configured (distinct from "configured but disabled", which still returns a summary with isEnabled: false). */
|
||||
export async function getLoyaltySummary(
|
||||
prisma: PrismaClient,
|
||||
params: { customerId: string; businessId: string },
|
||||
): Promise<LoyaltySummary | null> {
|
||||
const programme = await prisma.loyaltyProgramme.findUnique({
|
||||
where: { businessId: params.businessId },
|
||||
});
|
||||
if (!programme) return null;
|
||||
|
||||
const account = await prisma.loyaltyAccount.findUnique({
|
||||
where: {
|
||||
customerId_loyaltyProgrammeId: {
|
||||
customerId: params.customerId,
|
||||
loyaltyProgrammeId: programme.id,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
rewards: { where: { status: "AVAILABLE" }, orderBy: { createdAt: "desc" }, take: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
isEnabled: programme.isEnabled,
|
||||
accountId: account?.id ?? null,
|
||||
currentStamps: account?.currentStamps ?? 0,
|
||||
stampsRequired: programme.stampsRequired,
|
||||
availableReward: account?.rewards[0]
|
||||
? {
|
||||
id: account.rewards[0].id,
|
||||
label: account.rewards[0].label,
|
||||
createdAt: account.rewards[0].createdAt,
|
||||
}
|
||||
: null,
|
||||
rewardLabel: programme.rewardLabel,
|
||||
stampIcon: programme.stampIcon,
|
||||
cardBackgroundColor: programme.cardBackgroundColor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched version of getLoyaltySummary's "does this customer have a reward
|
||||
* waiting" check, for rendering a loyalty indicator across a whole day's
|
||||
* appointments (the calendar) without one query per customer. Returns an
|
||||
* empty map if loyalty isn't configured/enabled at all.
|
||||
*/
|
||||
export async function getAvailableRewardFlags(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; customerIds: string[] },
|
||||
): Promise<Map<string, boolean>> {
|
||||
if (params.customerIds.length === 0) return new Map();
|
||||
|
||||
const programme = await prisma.loyaltyProgramme.findUnique({
|
||||
where: { businessId: params.businessId },
|
||||
});
|
||||
if (!programme || !programme.isEnabled) return new Map();
|
||||
|
||||
const accounts = await prisma.loyaltyAccount.findMany({
|
||||
where: { loyaltyProgrammeId: programme.id, customerId: { in: params.customerIds } },
|
||||
select: {
|
||||
customerId: true,
|
||||
rewards: { where: { status: "AVAILABLE" }, select: { id: true }, take: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
return new Map(accounts.map((a) => [a.customerId, a.rewards.length > 0]));
|
||||
}
|
||||
|
||||
export interface CustomerLoyaltyBrief {
|
||||
currentStamps: number;
|
||||
stampsRequired: number;
|
||||
hasAvailableReward: boolean;
|
||||
}
|
||||
|
||||
/** Fuller batched summary — current stamps + threshold + reward flag — for the staff customer directory, one query for the whole list rather than one per row. */
|
||||
export async function getLoyaltySummariesForCustomers(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; customerIds: string[] },
|
||||
): Promise<Map<string, CustomerLoyaltyBrief>> {
|
||||
if (params.customerIds.length === 0) return new Map();
|
||||
|
||||
const programme = await prisma.loyaltyProgramme.findUnique({
|
||||
where: { businessId: params.businessId },
|
||||
});
|
||||
if (!programme || !programme.isEnabled) return new Map();
|
||||
|
||||
const accounts = await prisma.loyaltyAccount.findMany({
|
||||
where: { loyaltyProgrammeId: programme.id, customerId: { in: params.customerIds } },
|
||||
select: {
|
||||
customerId: true,
|
||||
currentStamps: true,
|
||||
rewards: { where: { status: "AVAILABLE" }, select: { id: true }, take: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
return new Map(
|
||||
accounts.map((a) => [
|
||||
a.customerId,
|
||||
{
|
||||
currentStamps: a.currentStamps,
|
||||
stampsRequired: programme.stampsRequired,
|
||||
hasAvailableReward: a.rewards.length > 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function getOrCreateAccount(
|
||||
prisma: PrismaClient,
|
||||
params: { customerId: string; loyaltyProgrammeId: string },
|
||||
) {
|
||||
return prisma.loyaltyAccount.upsert({
|
||||
where: {
|
||||
customerId_loyaltyProgrammeId: {
|
||||
customerId: params.customerId,
|
||||
loyaltyProgrammeId: params.loyaltyProgrammeId,
|
||||
},
|
||||
},
|
||||
create: { customerId: params.customerId, loyaltyProgrammeId: params.loyaltyProgrammeId },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty eligible-services/eligible-staff list means "everyone/everything
|
||||
* is eligible" (see schema.prisma) — an explicit row only exists to
|
||||
* *restrict* eligibility. A visit counts if at least one of its services is
|
||||
* eligible (the common "any qualifying service in this visit earns a
|
||||
* stamp" rule), not only if every service is.
|
||||
*/
|
||||
async function checkEligibility(
|
||||
prisma: PrismaClient,
|
||||
params: { loyaltyProgrammeId: string; serviceIds: string[]; staffId: string },
|
||||
): Promise<ValidationResult<LoyaltyErrorCode>> {
|
||||
const [eligibleServices, eligibleStaff] = await Promise.all([
|
||||
prisma.loyaltyEligibleService.findMany({
|
||||
where: { loyaltyProgrammeId: params.loyaltyProgrammeId },
|
||||
}),
|
||||
prisma.loyaltyEligibleStaff.findMany({
|
||||
where: { loyaltyProgrammeId: params.loyaltyProgrammeId },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (eligibleStaff.length > 0 && !eligibleStaff.some((r) => r.staffId === params.staffId)) {
|
||||
return fail({
|
||||
code: "NOT_ELIGIBLE_STAFF",
|
||||
message: "This staff member's visits don't earn loyalty stamps.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
eligibleServices.length > 0 &&
|
||||
!params.serviceIds.some((id) => eligibleServices.some((r) => r.serviceId === id))
|
||||
) {
|
||||
return fail({
|
||||
code: "NOT_ELIGIBLE_SERVICE",
|
||||
message: "None of the services on this visit earn a loyalty stamp.",
|
||||
});
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
interface AddStampResult {
|
||||
success: boolean;
|
||||
alreadyAwarded?: boolean;
|
||||
rewardCreated?: boolean;
|
||||
validation: ValidationResult<LoyaltyErrorCode>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The auto-award path, called once by the "complete appointment" Server
|
||||
* Action after AppointmentService has already transitioned the status —
|
||||
* this function never changes appointment status itself. Idempotent: if a
|
||||
* STAMP_ADDED transaction already references this appointment (e.g. the
|
||||
* action is somehow triggered twice), it returns success without creating
|
||||
* a second one — the brief's explicit "should not receive the same
|
||||
* automatic stamp twice".
|
||||
*/
|
||||
export async function awardStampForCompletedAppointment(
|
||||
prisma: PrismaClient,
|
||||
params: { appointmentId: string; staffId: string },
|
||||
): Promise<AddStampResult> {
|
||||
const appointment = await prisma.appointment.findUnique({
|
||||
where: { id: params.appointmentId },
|
||||
include: { services: true },
|
||||
});
|
||||
if (!appointment)
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
|
||||
};
|
||||
if (appointment.status !== "COMPLETED") {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({
|
||||
code: "APPOINTMENT_NOT_COMPLETED",
|
||||
message: "Only a completed appointment can earn a stamp.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const programme = await prisma.loyaltyProgramme.findUnique({
|
||||
where: { businessId: appointment.businessId },
|
||||
});
|
||||
if (!programme || !programme.isEnabled) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({
|
||||
code: "LOYALTY_DISABLED",
|
||||
message: "Loyalty isn't enabled for this business.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const eligibility = await checkEligibility(prisma, {
|
||||
loyaltyProgrammeId: programme.id,
|
||||
serviceIds: appointment.services.map((s) => s.serviceId),
|
||||
staffId: appointment.staffId,
|
||||
});
|
||||
if (!eligibility.valid) return { success: false, validation: eligibility };
|
||||
|
||||
const account = await getOrCreateAccount(prisma, {
|
||||
customerId: appointment.customerId,
|
||||
loyaltyProgrammeId: programme.id,
|
||||
});
|
||||
|
||||
const alreadyAwarded = await prisma.loyaltyTransaction.findFirst({
|
||||
where: { loyaltyAccountId: account.id, appointmentId: appointment.id, type: "STAMP_ADDED" },
|
||||
});
|
||||
if (alreadyAwarded) return { success: true, alreadyAwarded: true, validation: ok() };
|
||||
|
||||
const { rewardCreated } = await addStampTransaction(prisma, {
|
||||
loyaltyAccountId: account.id,
|
||||
amount: 1, // one stamp per completed visit — see file header; not a per-service count
|
||||
appointmentId: appointment.id,
|
||||
authoredByStaffId: params.staffId,
|
||||
reason: null,
|
||||
programme,
|
||||
});
|
||||
|
||||
return { success: true, rewardCreated, validation: ok() };
|
||||
}
|
||||
|
||||
/** The manual "Add Stamp" action a staff member triggers from Customer Details / Current Appointment — not tied to a completion event, so no duplicate-award check applies (every manual add is its own deliberate transaction). */
|
||||
export async function addStamp(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
customerId: string;
|
||||
businessId: string;
|
||||
amount: number;
|
||||
staffId: string;
|
||||
appointmentId?: string;
|
||||
reason?: string;
|
||||
},
|
||||
): Promise<AddStampResult> {
|
||||
if (!Number.isInteger(params.amount) || params.amount <= 0) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "INVALID_AMOUNT", message: "Enter a positive number of stamps." }),
|
||||
};
|
||||
}
|
||||
|
||||
const programme = await prisma.loyaltyProgramme.findUnique({
|
||||
where: { businessId: params.businessId },
|
||||
});
|
||||
if (!programme || !programme.isEnabled) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({
|
||||
code: "LOYALTY_DISABLED",
|
||||
message: "Loyalty isn't enabled for this business.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const account = await getOrCreateAccount(prisma, {
|
||||
customerId: params.customerId,
|
||||
loyaltyProgrammeId: programme.id,
|
||||
});
|
||||
|
||||
const { rewardCreated } = await addStampTransaction(prisma, {
|
||||
loyaltyAccountId: account.id,
|
||||
amount: params.amount,
|
||||
appointmentId: params.appointmentId,
|
||||
authoredByStaffId: params.staffId,
|
||||
reason: params.reason ?? null,
|
||||
programme,
|
||||
});
|
||||
|
||||
return { success: true, rewardCreated, validation: ok() };
|
||||
}
|
||||
|
||||
async function addStampTransaction(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
loyaltyAccountId: string;
|
||||
amount: number;
|
||||
appointmentId?: string;
|
||||
authoredByStaffId: string;
|
||||
reason: string | null;
|
||||
programme: LoyaltyProgramme;
|
||||
},
|
||||
): Promise<{ rewardCreated: boolean }> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const account = await tx.loyaltyAccount.update({
|
||||
where: { id: params.loyaltyAccountId },
|
||||
data: { currentStamps: { increment: params.amount } },
|
||||
});
|
||||
await tx.loyaltyTransaction.create({
|
||||
data: {
|
||||
loyaltyAccountId: params.loyaltyAccountId,
|
||||
type: "STAMP_ADDED",
|
||||
amount: params.amount,
|
||||
reason: params.reason,
|
||||
appointmentId: params.appointmentId,
|
||||
authoredByStaffId: params.authoredByStaffId,
|
||||
},
|
||||
});
|
||||
|
||||
let rewardCreated = false;
|
||||
if (account.currentStamps >= params.programme.stampsRequired) {
|
||||
const existingAvailable = await tx.loyaltyReward.findFirst({
|
||||
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
|
||||
});
|
||||
// Crossing the threshold again while a reward is already waiting to be
|
||||
// redeemed doesn't stack a second one.
|
||||
if (!existingAvailable) {
|
||||
await tx.loyaltyReward.create({
|
||||
data: {
|
||||
loyaltyAccountId: account.id,
|
||||
rewardType: params.programme.rewardType,
|
||||
value: params.programme.rewardValue,
|
||||
label: params.programme.rewardLabel ?? "Reward",
|
||||
status: "AVAILABLE",
|
||||
expiresAt: params.programme.rewardExpiresAfterDays
|
||||
? new Date(Date.now() + params.programme.rewardExpiresAfterDays * 86_400_000)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
rewardCreated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { rewardCreated };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects an accidental stamp — always removes (never adds; use `addStamp`
|
||||
* for that), always requires a reason, and always creates a compensating
|
||||
* STAMP_REMOVED transaction rather than editing or deleting the original
|
||||
* entry. The cached total is clamped at zero so a correction can never make
|
||||
* it negative.
|
||||
*/
|
||||
export async function correctStamp(
|
||||
prisma: PrismaClient,
|
||||
params: { loyaltyAccountId: string; amount: number; staffId: string; reason: string },
|
||||
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode> }> {
|
||||
if (!params.reason.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({
|
||||
code: "REASON_REQUIRED",
|
||||
message: "A reason is required to correct a stamp.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (!Number.isInteger(params.amount) || params.amount <= 0) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({
|
||||
code: "INVALID_AMOUNT",
|
||||
message: "Enter a positive number of stamps to remove.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const account = await tx.loyaltyAccount.findUniqueOrThrow({
|
||||
where: { id: params.loyaltyAccountId },
|
||||
});
|
||||
const clampedRemoval = Math.min(params.amount, account.currentStamps);
|
||||
|
||||
await tx.loyaltyAccount.update({
|
||||
where: { id: params.loyaltyAccountId },
|
||||
data: { currentStamps: { decrement: clampedRemoval } },
|
||||
});
|
||||
await tx.loyaltyTransaction.create({
|
||||
data: {
|
||||
loyaltyAccountId: params.loyaltyAccountId,
|
||||
type: "STAMP_REMOVED",
|
||||
amount: -clampedRemoval,
|
||||
reason: params.reason,
|
||||
authoredByStaffId: params.staffId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true, validation: ok() };
|
||||
}
|
||||
|
||||
export async function getLoyaltyHistory(
|
||||
prisma: PrismaClient,
|
||||
params: { loyaltyAccountId: string; limit?: number },
|
||||
) {
|
||||
return prisma.loyaltyTransaction.findMany({
|
||||
where: { loyaltyAccountId: params.loyaltyAccountId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: params.limit ?? 20,
|
||||
include: {
|
||||
authoredByStaff: { include: { user: { select: { name: true } } } },
|
||||
appointment: { select: { id: true, startAt: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AppointmentStatus } from "@/generated/prisma/enums";
|
||||
import { canTransition, validateTransition } from "../appointment.service";
|
||||
import { canTransition, selectCurrentAppointment, validateTransition } from "../appointment.service";
|
||||
|
||||
const ALL_STATUSES = Object.values(AppointmentStatus);
|
||||
|
||||
@@ -65,3 +65,66 @@ describe("validateTransition", () => {
|
||||
expect(result.issues[0]?.code).toBe("INVALID_STATUS_TRANSITION");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectCurrentAppointment", () => {
|
||||
const now = new Date("2026-08-02T12:00:00.000Z");
|
||||
|
||||
interface Fixture {
|
||||
id: string;
|
||||
status: AppointmentStatus;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
}
|
||||
|
||||
function appt(id: string, status: AppointmentStatus, startOffsetMin: number, durationMin = 30): Fixture {
|
||||
const startAt = new Date(now.getTime() + startOffsetMin * 60_000);
|
||||
return { id, status, startAt, endAt: new Date(startAt.getTime() + durationMin * 60_000) };
|
||||
}
|
||||
|
||||
it("returns null for an empty list", () => {
|
||||
expect(selectCurrentAppointment([], now)).toBeNull();
|
||||
});
|
||||
|
||||
it("tier 1: prefers IN_PROGRESS over everything else", () => {
|
||||
const inProgress = appt("in-progress", AppointmentStatus.IN_PROGRESS, -10, 30);
|
||||
const checkedIn = appt("checked-in", AppointmentStatus.CHECKED_IN, 5, 30);
|
||||
const scheduledNow = appt("scheduled-now", AppointmentStatus.CONFIRMED, -5, 30);
|
||||
const result = selectCurrentAppointment([checkedIn, scheduledNow, inProgress], now);
|
||||
expect(result?.id).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("tier 2: CHECKED_IN wins when nothing is IN_PROGRESS", () => {
|
||||
const checkedIn = appt("checked-in", AppointmentStatus.CHECKED_IN, 5, 30);
|
||||
const upcoming = appt("upcoming", AppointmentStatus.CONFIRMED, 120, 30);
|
||||
const result = selectCurrentAppointment([upcoming, checkedIn], now);
|
||||
expect(result?.id).toBe("checked-in");
|
||||
});
|
||||
|
||||
it("tier 3: a CONFIRMED appointment whose window contains now, but never checked in", () => {
|
||||
const scheduledNow = appt("scheduled-now", AppointmentStatus.CONFIRMED, -5, 30);
|
||||
const upcoming = appt("upcoming", AppointmentStatus.CONFIRMED, 120, 30);
|
||||
const result = selectCurrentAppointment([upcoming, scheduledNow], now);
|
||||
expect(result?.id).toBe("scheduled-now");
|
||||
});
|
||||
|
||||
it("tier 4: falls back to the next upcoming appointment today, earliest first", () => {
|
||||
const later = appt("later", AppointmentStatus.CONFIRMED, 180, 30);
|
||||
const sooner = appt("sooner", AppointmentStatus.PENDING, 60, 30);
|
||||
const result = selectCurrentAppointment([later, sooner], now);
|
||||
expect(result?.id).toBe("sooner");
|
||||
});
|
||||
|
||||
it("tier 5: null when every appointment is terminal or in the past", () => {
|
||||
const completed = appt("completed", AppointmentStatus.COMPLETED, -120, 30);
|
||||
const cancelled = appt("cancelled", AppointmentStatus.CANCELLED, 60, 30);
|
||||
const noShow = appt("no-show", AppointmentStatus.NO_SHOW, -60, 30);
|
||||
const result = selectCurrentAppointment([completed, cancelled, noShow], now);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores CANCELLED and NO_SHOW rows even when their window contains now", () => {
|
||||
const cancelledNow = appt("cancelled-now", AppointmentStatus.CANCELLED, -5, 30);
|
||||
const result = selectCurrentAppointment([cancelledNow], now);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -233,6 +233,48 @@ describe("booking engine (integration)", () => {
|
||||
});
|
||||
expect(slots.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("a partial-day (Block Time) entry only blocks its own window, not the whole day", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
await prisma.timeOff.create({
|
||||
data: {
|
||||
staffId,
|
||||
startDate: new Date(`${monday}T00:00:00.000Z`),
|
||||
endDate: new Date(`${monday}T00:00:00.000Z`),
|
||||
status: "APPROVED",
|
||||
reason: "Team meeting",
|
||||
startMinutes: 12 * 60,
|
||||
endMinutes: 13 * 60,
|
||||
},
|
||||
});
|
||||
|
||||
const { slots } = await getAvailableTimeSlots(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
date: monday,
|
||||
});
|
||||
// Slots outside the block still exist — this is not a whole-day block.
|
||||
expect(slots.length).toBeGreaterThan(0);
|
||||
const duringBlock = slots.some((s) => s.startMinutes >= 12 * 60 && s.startMinutes < 13 * 60);
|
||||
expect(duringBlock).toBe(false);
|
||||
|
||||
const { validation: insideBlock } = await validateBookingSlot(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
staffId,
|
||||
startAt: wallClockToInstant(monday, 12 * 60 + 15, business.timezone),
|
||||
});
|
||||
expect(insideBlock.valid).toBe(false);
|
||||
expect(insideBlock.issues.map((i) => i.code)).toContain("STAFF_ON_LEAVE");
|
||||
|
||||
const { validation: outsideBlock } = await validateBookingSlot(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
staffId,
|
||||
startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
|
||||
});
|
||||
expect(outsideBlock.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple services", () => {
|
||||
|
||||
@@ -27,7 +27,10 @@ export function canTransition(from: AppointmentStatus, to: AppointmentStatus): b
|
||||
return TRANSITIONS[from].includes(to);
|
||||
}
|
||||
|
||||
export function validateTransition(from: AppointmentStatus, to: AppointmentStatus): ValidationResult {
|
||||
export function validateTransition(
|
||||
from: AppointmentStatus,
|
||||
to: AppointmentStatus,
|
||||
): ValidationResult {
|
||||
if (!canTransition(from, to)) {
|
||||
return fail({
|
||||
code: "INVALID_STATUS_TRANSITION",
|
||||
@@ -85,11 +88,18 @@ export type TransitionResult =
|
||||
| { success: true; appointment: NonNullable<Awaited<ReturnType<typeof getAppointmentById>>> }
|
||||
| { success: false; validation: ValidationResult };
|
||||
|
||||
async function transitionStatus(prisma: PrismaClient, appointmentId: string, to: AppointmentStatus): Promise<TransitionResult> {
|
||||
async function transitionStatus(
|
||||
prisma: PrismaClient,
|
||||
appointmentId: string,
|
||||
to: AppointmentStatus,
|
||||
): Promise<TransitionResult> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const appointment = await tx.appointment.findUnique({ where: { id: appointmentId } });
|
||||
if (!appointment) {
|
||||
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
|
||||
};
|
||||
}
|
||||
|
||||
const check = validateTransition(appointment.status, to);
|
||||
@@ -123,3 +133,43 @@ export function markNoShow(prisma: PrismaClient, appointmentId: string) {
|
||||
export function confirm(prisma: PrismaClient, appointmentId: string) {
|
||||
return transitionStatus(prisma, appointmentId, AppointmentStatus.CONFIRMED);
|
||||
}
|
||||
|
||||
const CURRENT_APPOINTMENT_ACTIVE_STATUSES: AppointmentStatus[] = [
|
||||
AppointmentStatus.PENDING,
|
||||
AppointmentStatus.CONFIRMED,
|
||||
AppointmentStatus.CHECKED_IN,
|
||||
AppointmentStatus.IN_PROGRESS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Which of a staff member's appointments is "the current one" for the
|
||||
* Current Appointment screen — a pure function over already-fetched
|
||||
* appointments (the page fetches today's list via listAppointments; this
|
||||
* just picks the most relevant one), so it's cheap to unit test every edge
|
||||
* case without a database. Priority, per the Phase 5 brief:
|
||||
* 1. IN_PROGRESS
|
||||
* 2. CHECKED_IN
|
||||
* 3. Scheduled right now (startAt <= now < endAt) and not yet terminal
|
||||
* 4. The next upcoming one today
|
||||
* 5. null — the page shows its own empty state
|
||||
*/
|
||||
export function selectCurrentAppointment<
|
||||
T extends { status: AppointmentStatus; startAt: Date; endAt: Date },
|
||||
>(appointments: T[], now: Date): T | null {
|
||||
const inProgress = appointments.find((a) => a.status === AppointmentStatus.IN_PROGRESS);
|
||||
if (inProgress) return inProgress;
|
||||
|
||||
const checkedIn = appointments.find((a) => a.status === AppointmentStatus.CHECKED_IN);
|
||||
if (checkedIn) return checkedIn;
|
||||
|
||||
const scheduledNow = appointments.find(
|
||||
(a) =>
|
||||
CURRENT_APPOINTMENT_ACTIVE_STATUSES.includes(a.status) && a.startAt <= now && a.endAt > now,
|
||||
);
|
||||
if (scheduledNow) return scheduledNow;
|
||||
|
||||
const upcoming = appointments
|
||||
.filter((a) => CURRENT_APPOINTMENT_ACTIVE_STATUSES.includes(a.status) && a.startAt > now)
|
||||
.sort((a, b) => a.startAt.getTime() - b.startAt.getTime());
|
||||
return upcoming[0] ?? null;
|
||||
}
|
||||
@@ -453,10 +453,10 @@ export async function validateBookingSlot(
|
||||
};
|
||||
}
|
||||
|
||||
const [workingHours, breakDays, activeTimeOff, businessCount, staffCount] = await Promise.all([
|
||||
const [workingHours, breakDays, timeOffOnDate, businessCount, staffCount] = await Promise.all([
|
||||
prisma.workingHours.findUnique({ where: { staffId_dayOfWeek: { staffId: params.staffId, dayOfWeek: weekdayOf(date) } } }),
|
||||
prisma.breakDay.findMany({ where: { dayOfWeek: weekdayOf(date), break: { staffId: params.staffId } }, include: { break: true } }),
|
||||
prisma.timeOff.findFirst({
|
||||
prisma.timeOff.findMany({
|
||||
where: {
|
||||
staffId: params.staffId,
|
||||
status: "APPROVED",
|
||||
@@ -490,11 +490,24 @@ export async function validateBookingSlot(
|
||||
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_NOT_WORKING", message: "This staff member isn't scheduled to work at this time." })) };
|
||||
}
|
||||
|
||||
if (activeTimeOff) {
|
||||
// A row with both minutes null blocks the whole day; a row with minutes
|
||||
// set (including a one-off "Block Time" entry — see TimeOff.startMinutes
|
||||
// in schema.prisma) only conflicts if it overlaps the requested slot.
|
||||
if (timeOffOnDate.some((t) => t.startMinutes === null)) {
|
||||
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_LEAVE", message: "This staff member is on approved leave that day." })) };
|
||||
}
|
||||
const partialTimeOff: TimeWindow[] = timeOffOnDate
|
||||
.filter((t) => t.startMinutes !== null && t.endMinutes !== null)
|
||||
.map((t) => ({ startMinutes: t.startMinutes!, endMinutes: t.endMinutes! }));
|
||||
const onTimeOff = partialTimeOff.some((w) => startMinutes < w.endMinutes && endMinutes > w.startMinutes);
|
||||
if (onTimeOff) {
|
||||
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_LEAVE", message: "This staff member is unavailable during part of this time." })) };
|
||||
}
|
||||
|
||||
const breaks: TimeWindow[] = breakDays.map((bd) => ({ startMinutes: bd.break.startMinutes, endMinutes: bd.break.endMinutes }));
|
||||
const breaks: TimeWindow[] = [
|
||||
...breakDays.map((bd) => ({ startMinutes: bd.break.startMinutes, endMinutes: bd.break.endMinutes })),
|
||||
...partialTimeOff,
|
||||
];
|
||||
const onBreak = breaks.some((b) => startMinutes < b.endMinutes && endMinutes > b.startMinutes);
|
||||
if (onBreak) {
|
||||
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_BREAK", message: "This staff member is on a break during part of this time." })) };
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { AppointmentStatus } from "@/generated/prisma/enums";
|
||||
import { calendarDateOf, minutesFromMidnight } from "@/lib/time/business-time";
|
||||
import { getAppointmentById } from "./appointment.service";
|
||||
import { getAvailableTimeSlots, resolveBookingContext, validateBookingSlot } from "./availability.service";
|
||||
import {
|
||||
getAvailableTimeSlots,
|
||||
resolveBookingContext,
|
||||
validateBookingSlot,
|
||||
} from "./availability.service";
|
||||
import { isLateCancellation, isLateReschedule } from "./business-rules.service";
|
||||
import { DoubleBookingError, createAppointment, rescheduleAppointment } from "./scheduling.service";
|
||||
import { countStaffAppointmentsOnDate } from "./staff-availability.service";
|
||||
@@ -24,11 +28,14 @@ export interface CreateBookingInput {
|
||||
staffId?: string;
|
||||
startAt: Date;
|
||||
notes?: string;
|
||||
/** Who initiated this booking — see Appointment.createdByUserId in schema.prisma. Omit for a guest's own self-service booking. */
|
||||
createdByUserId?: string;
|
||||
}
|
||||
|
||||
export type Appointment = NonNullable<Awaited<ReturnType<typeof getAppointmentById>>>;
|
||||
|
||||
export type BookingResult = { success: true; appointment: Appointment } | { success: false; validation: ValidationResult };
|
||||
export type BookingResult =
|
||||
{ success: true; appointment: Appointment } | { success: false; validation: ValidationResult };
|
||||
|
||||
/** Among staff free at this slot, prefer whoever has the fewest appointments that day — spreads "any barber" bookings out instead of always picking the same one. */
|
||||
async function pickLeastBusyStaff(
|
||||
@@ -40,7 +47,9 @@ async function pickLeastBusyStaff(
|
||||
if (candidateStaffIds.length === 1) return candidateStaffIds[0]!;
|
||||
|
||||
const counts = await Promise.all(
|
||||
candidateStaffIds.map((staffId) => countStaffAppointmentsOnDate(prisma, { staffId, date, businessTimezone })),
|
||||
candidateStaffIds.map((staffId) =>
|
||||
countStaffAppointmentsOnDate(prisma, { staffId, date, businessTimezone }),
|
||||
),
|
||||
);
|
||||
|
||||
let best = candidateStaffIds[0]!;
|
||||
@@ -55,7 +64,10 @@ async function pickLeastBusyStaff(
|
||||
return best;
|
||||
}
|
||||
|
||||
export async function createBooking(prisma: PrismaClient, input: CreateBookingInput): Promise<BookingResult> {
|
||||
export async function createBooking(
|
||||
prisma: PrismaClient,
|
||||
input: CreateBookingInput,
|
||||
): Promise<BookingResult> {
|
||||
const { context: baseContext, validation: baseValidation } = await resolveBookingContext(prisma, {
|
||||
businessId: input.businessId,
|
||||
serviceIds: input.serviceIds,
|
||||
@@ -68,7 +80,10 @@ export async function createBooking(prisma: PrismaClient, input: CreateBookingIn
|
||||
if (baseContext.eligibleStaffIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "STAFF_CANNOT_PERFORM_SERVICE", message: "No staff can perform the selected services." }),
|
||||
validation: fail({
|
||||
code: "STAFF_CANNOT_PERFORM_SERVICE",
|
||||
message: "No staff can perform the selected services.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,9 +96,20 @@ export async function createBooking(prisma: PrismaClient, input: CreateBookingIn
|
||||
});
|
||||
const matching = slots.find((s) => s.startMinutes === startMinutes);
|
||||
if (!matching || matching.staffOptions.length === 0) {
|
||||
return { success: false, validation: fail({ code: "SLOT_UNAVAILABLE", message: "This time is no longer available." }) };
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({
|
||||
code: "SLOT_UNAVAILABLE",
|
||||
message: "This time is no longer available.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
staffId = await pickLeastBusyStaff(prisma, matching.staffOptions, date, baseContext.business.timezone);
|
||||
staffId = await pickLeastBusyStaff(
|
||||
prisma,
|
||||
matching.staffOptions,
|
||||
date,
|
||||
baseContext.business.timezone,
|
||||
);
|
||||
}
|
||||
|
||||
const { validation, context, endAt } = await validateBookingSlot(prisma, {
|
||||
@@ -94,7 +120,9 @@ export async function createBooking(prisma: PrismaClient, input: CreateBookingIn
|
||||
});
|
||||
if (!validation.valid || !context || !endAt) return { success: false, validation };
|
||||
|
||||
const status = context.bookingRule.requireApproval ? AppointmentStatus.PENDING : AppointmentStatus.CONFIRMED;
|
||||
const status = context.bookingRule.requireApproval
|
||||
? AppointmentStatus.PENDING
|
||||
: AppointmentStatus.CONFIRMED;
|
||||
|
||||
try {
|
||||
const created = await createAppointment(prisma, {
|
||||
@@ -105,6 +133,7 @@ export async function createBooking(prisma: PrismaClient, input: CreateBookingIn
|
||||
endAt,
|
||||
status,
|
||||
notes: input.notes,
|
||||
createdByUserId: input.createdByUserId,
|
||||
effectiveBuffer: context.effectiveBuffer,
|
||||
services: context.services.map((s) => ({
|
||||
serviceId: s.serviceId,
|
||||
@@ -118,7 +147,10 @@ export async function createBooking(prisma: PrismaClient, input: CreateBookingIn
|
||||
return { success: true, appointment: appointment! };
|
||||
} catch (error) {
|
||||
if (error instanceof DoubleBookingError) {
|
||||
return { success: false, validation: fail({ code: "SLOT_UNAVAILABLE", message: error.message }) };
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "SLOT_UNAVAILABLE", message: error.message }),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -139,10 +171,16 @@ const NON_RESCHEDULABLE_STATUSES: AppointmentStatus[] = [
|
||||
];
|
||||
|
||||
/** Rescheduling performs the same validation as a new booking — it's a new slot, just for an appointment that already exists. */
|
||||
export async function rescheduleBooking(prisma: PrismaClient, input: RescheduleBookingInput): Promise<BookingResult> {
|
||||
export async function rescheduleBooking(
|
||||
prisma: PrismaClient,
|
||||
input: RescheduleBookingInput,
|
||||
): Promise<BookingResult> {
|
||||
const existing = await getAppointmentById(prisma, input.appointmentId);
|
||||
if (!existing) {
|
||||
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
|
||||
};
|
||||
}
|
||||
if (NON_RESCHEDULABLE_STATUSES.includes(existing.status)) {
|
||||
return {
|
||||
@@ -178,7 +216,10 @@ export async function rescheduleBooking(prisma: PrismaClient, input: RescheduleB
|
||||
return { success: true, appointment: appointment! };
|
||||
} catch (error) {
|
||||
if (error instanceof DoubleBookingError) {
|
||||
return { success: false, validation: fail({ code: "SLOT_UNAVAILABLE", message: error.message }) };
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "SLOT_UNAVAILABLE", message: error.message }),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -214,10 +255,16 @@ const NON_CANCELLABLE_STATUSES: AppointmentStatus[] = [
|
||||
];
|
||||
|
||||
/** Never deletes — sets status to CANCELLED and records when, why, and by whom. */
|
||||
export async function cancelBooking(prisma: PrismaClient, input: CancelBookingInput): Promise<CancelBookingResult> {
|
||||
export async function cancelBooking(
|
||||
prisma: PrismaClient,
|
||||
input: CancelBookingInput,
|
||||
): Promise<CancelBookingResult> {
|
||||
const existing = await getAppointmentById(prisma, input.appointmentId);
|
||||
if (!existing) {
|
||||
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
|
||||
};
|
||||
}
|
||||
if (NON_CANCELLABLE_STATUSES.includes(existing.status)) {
|
||||
return {
|
||||
@@ -234,7 +281,11 @@ export async function cancelBooking(prisma: PrismaClient, input: CancelBookingIn
|
||||
select: { bookingRule: { select: { cancellationWindowHours: true } } },
|
||||
});
|
||||
const cancellationWindowHours = business?.bookingRule?.cancellationWindowHours ?? 24;
|
||||
const wasLateCancellation = isLateCancellation(existing.startAt, new Date(), cancellationWindowHours);
|
||||
const wasLateCancellation = isLateCancellation(
|
||||
existing.startAt,
|
||||
new Date(),
|
||||
cancellationWindowHours,
|
||||
);
|
||||
|
||||
const appointment = await prisma.appointment.update({
|
||||
where: { id: input.appointmentId },
|
||||
@@ -244,13 +295,21 @@ export async function cancelBooking(prisma: PrismaClient, input: CancelBookingIn
|
||||
cancelledByUserId: input.cancelledByUserId,
|
||||
cancellationReason: input.reason,
|
||||
},
|
||||
include: { services: { include: { service: true } }, staff: { include: { user: true } }, customer: true },
|
||||
include: {
|
||||
services: { include: { service: true } },
|
||||
staff: { include: { user: true } },
|
||||
customer: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, appointment, wasLateCancellation, validation: ok() };
|
||||
}
|
||||
|
||||
/** Exposed for symmetry with cancellation's "late" flag — a UI can warn before submitting a reschedule request. */
|
||||
export function checkRescheduleWindow(appointmentStart: Date, now: Date, rescheduleWindowHours: number): boolean {
|
||||
export function checkRescheduleWindow(
|
||||
appointmentStart: Date,
|
||||
now: Date,
|
||||
rescheduleWindowHours: number,
|
||||
): boolean {
|
||||
return isLateReschedule(appointmentStart, now, rescheduleWindowHours);
|
||||
}
|
||||
@@ -46,6 +46,8 @@ export interface CreateAppointmentParams {
|
||||
endAt: Date;
|
||||
status: AppointmentStatus;
|
||||
notes?: string;
|
||||
/** Who initiated this booking — see Appointment.createdByUserId in schema.prisma. Omitted for a guest customer's own self-service booking. */
|
||||
createdByUserId?: string;
|
||||
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
|
||||
services: AppointmentServiceLine[];
|
||||
}
|
||||
@@ -60,10 +62,20 @@ export interface RescheduleAppointmentParams {
|
||||
|
||||
async function assertNoConflict(
|
||||
tx: PrismaClient,
|
||||
params: { staffId: string; startAt: Date; endAt: Date; effectiveBuffer: { beforeMinutes: number; afterMinutes: number }; excludeAppointmentId?: string },
|
||||
params: {
|
||||
staffId: string;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
|
||||
excludeAppointmentId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const bufferedStart = new Date(params.startAt.getTime() - params.effectiveBuffer.beforeMinutes * 60_000);
|
||||
const bufferedEnd = new Date(params.endAt.getTime() + params.effectiveBuffer.afterMinutes * 60_000);
|
||||
const bufferedStart = new Date(
|
||||
params.startAt.getTime() - params.effectiveBuffer.beforeMinutes * 60_000,
|
||||
);
|
||||
const bufferedEnd = new Date(
|
||||
params.endAt.getTime() + params.effectiveBuffer.afterMinutes * 60_000,
|
||||
);
|
||||
|
||||
const conflict = await tx.appointment.findFirst({
|
||||
where: {
|
||||
@@ -110,7 +122,10 @@ function isSerializationFailure(error: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runSerializable<T>(prisma: PrismaClient, fn: (tx: PrismaClient) => Promise<T>): Promise<T> {
|
||||
async function runSerializable<T>(
|
||||
prisma: PrismaClient,
|
||||
fn: (tx: PrismaClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await prisma.$transaction(fn as (tx: Prisma.TransactionClient) => Promise<T>, {
|
||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||
@@ -139,6 +154,7 @@ export async function createAppointment(prisma: PrismaClient, params: CreateAppo
|
||||
endAt: params.endAt,
|
||||
status: params.status,
|
||||
notes: params.notes,
|
||||
createdByUserId: params.createdByUserId,
|
||||
services: {
|
||||
create: params.services.map((s) => ({
|
||||
serviceId: s.serviceId,
|
||||
@@ -152,7 +168,10 @@ export async function createAppointment(prisma: PrismaClient, params: CreateAppo
|
||||
});
|
||||
}
|
||||
|
||||
export async function rescheduleAppointment(prisma: PrismaClient, params: RescheduleAppointmentParams) {
|
||||
export async function rescheduleAppointment(
|
||||
prisma: PrismaClient,
|
||||
params: RescheduleAppointmentParams,
|
||||
) {
|
||||
return runSerializable(prisma, async (tx) => {
|
||||
await assertNoConflict(tx, {
|
||||
staffId: params.staffId,
|
||||
|
||||
@@ -116,21 +116,32 @@ export async function getStaffFreeWindowsForRange(
|
||||
: null;
|
||||
|
||||
const dateAsUtcMidnight = calendarDateToUtcMidnight(date);
|
||||
const onLeave = timeOffRows.some((t) => t.startDate <= dateAsUtcMidnight && t.endDate >= dateAsUtcMidnight);
|
||||
const timeOffOnDate = timeOffRows.filter((t) => t.startDate <= dateAsUtcMidnight && t.endDate >= dateAsUtcMidnight);
|
||||
// A row with both minutes null blocks the whole day (the original
|
||||
// behaviour); a row with minutes set — including a one-off "Block
|
||||
// Time" entry, see TimeOff.startMinutes in schema.prisma — only blocks
|
||||
// that window, so it's folded into `busy` below instead.
|
||||
const onLeave = timeOffOnDate.some((t) => t.startMinutes === null);
|
||||
const partialTimeOffBusy: TimeWindow[] = timeOffOnDate
|
||||
.filter((t) => t.startMinutes !== null && t.endMinutes !== null)
|
||||
.map((t) => ({ startMinutes: t.startMinutes!, endMinutes: t.endMinutes! }));
|
||||
|
||||
const dayStart = startOfBusinessDay(date, businessTimezone);
|
||||
const dayEnd = endOfBusinessDay(date, businessTimezone);
|
||||
const busy: TimeWindow[] = appointments
|
||||
.filter((a) => a.startAt < dayEnd && a.endAt > dayStart)
|
||||
.map((a) =>
|
||||
expandWithBuffer(
|
||||
{
|
||||
startMinutes: minutesFromMidnight(a.startAt, businessTimezone),
|
||||
endMinutes: minutesFromMidnight(a.endAt, businessTimezone),
|
||||
},
|
||||
effectiveBuffer,
|
||||
const busy: TimeWindow[] = [
|
||||
...appointments
|
||||
.filter((a) => a.startAt < dayEnd && a.endAt > dayStart)
|
||||
.map((a) =>
|
||||
expandWithBuffer(
|
||||
{
|
||||
startMinutes: minutesFromMidnight(a.startAt, businessTimezone),
|
||||
endMinutes: minutesFromMidnight(a.endAt, businessTimezone),
|
||||
},
|
||||
effectiveBuffer,
|
||||
),
|
||||
),
|
||||
);
|
||||
...partialTimeOffBusy,
|
||||
];
|
||||
|
||||
result.set(
|
||||
date,
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import {
|
||||
createBreak,
|
||||
createTimeOff,
|
||||
deleteBreak,
|
||||
deleteTimeOff,
|
||||
getWorkingHours,
|
||||
setWorkingHours,
|
||||
updateBreak,
|
||||
updateTimeOff,
|
||||
} from "../staff.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
|
||||
let date = addDays(todayIn(timezone), minDaysAhead);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (weekdayOf(date) === target) return date;
|
||||
date = addDays(date, 1);
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers the Breaks/Time Off conflict-resolution flow and the ownership
|
||||
* checks added to updateBreak/deleteBreak/updateTimeOff/deleteTimeOff — a
|
||||
* real bug caught during Phase 5 development where a permission mismatch
|
||||
* threw uncaught instead of returning the graceful {success:false} shape
|
||||
* every other StaffService function uses.
|
||||
*/
|
||||
describe("StaffService (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
describe("working hours", () => {
|
||||
it("round-trips a full week through setWorkingHours/getWorkingHours", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const result = await setWorkingHours(prisma, {
|
||||
staffId,
|
||||
days: [
|
||||
{ dayOfWeek: Weekday.MONDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 },
|
||||
{ dayOfWeek: Weekday.SUNDAY, isOff: true },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const hours = await getWorkingHours(prisma, staffId);
|
||||
const monday = hours.find((h) => h.dayOfWeek === Weekday.MONDAY);
|
||||
expect(monday?.startMinutes).toBe(9 * 60);
|
||||
expect(monday?.endMinutes).toBe(17 * 60);
|
||||
const sunday = hours.find((h) => h.dayOfWeek === Weekday.SUNDAY);
|
||||
expect(sunday?.isOff).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("breaks", () => {
|
||||
it("blocks creating a break that overlaps a future appointment on a matching day, and Save Anyway bypasses it", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
|
||||
|
||||
const appointment = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
|
||||
endAt: wallClockToInstant(monday, 12 * 60 + 30, business.timezone),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const blocked = await createBreak(prisma, {
|
||||
staffId,
|
||||
businessTimezone: business.timezone,
|
||||
input: { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.MONDAY] },
|
||||
});
|
||||
expect(blocked.success).toBe(false);
|
||||
if (blocked.success) throw new Error("unreachable");
|
||||
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
|
||||
|
||||
const acknowledged = await createBreak(prisma, {
|
||||
staffId,
|
||||
businessTimezone: business.timezone,
|
||||
input: { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.MONDAY] },
|
||||
acknowledgeConflicts: true,
|
||||
});
|
||||
expect(acknowledged.success).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a graceful failure — not a throw — when a staff member tries to update another staff member's break", async () => {
|
||||
const { staffId: owner } = await createTestStaff(business);
|
||||
const { staffId: intruder } = await createTestStaff(business);
|
||||
const created = await createBreak(prisma, {
|
||||
staffId: owner,
|
||||
businessTimezone: business.timezone,
|
||||
input: { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.TUESDAY] },
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
const result = await updateBreak(prisma, {
|
||||
breakId: created.break.id,
|
||||
staffId: intruder,
|
||||
businessTimezone: business.timezone,
|
||||
input: { label: "Hijacked", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.TUESDAY] },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) throw new Error("unreachable");
|
||||
expect(result.validation.issues[0]?.code).toBe("PERMISSION_DENIED");
|
||||
|
||||
// Unchanged in the database — the attempted hijack had no effect.
|
||||
const stillOwned = await prisma.break.findUniqueOrThrow({ where: { id: created.break.id } });
|
||||
expect(stillOwned.label).toBe("Lunch");
|
||||
});
|
||||
|
||||
it("returns {success:false} — not a throw — when a staff member tries to delete another staff member's break", async () => {
|
||||
const { staffId: owner } = await createTestStaff(business);
|
||||
const { staffId: intruder } = await createTestStaff(business);
|
||||
const created = await createBreak(prisma, {
|
||||
staffId: owner,
|
||||
businessTimezone: business.timezone,
|
||||
input: { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.WEDNESDAY] },
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
const result = await deleteBreak(prisma, { breakId: created.break.id, staffId: intruder });
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
const stillExists = await prisma.break.findUnique({ where: { id: created.break.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("time off", () => {
|
||||
it("blocks time off that overlaps a future appointment, and Save Anyway bypasses it", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
|
||||
|
||||
const appointment = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
|
||||
endAt: wallClockToInstant(monday, 12 * 60 + 30, business.timezone),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const blocked = await createTimeOff(prisma, {
|
||||
staffId,
|
||||
businessTimezone: business.timezone,
|
||||
input: { startDate: monday, endDate: monday, reason: "Day off" },
|
||||
});
|
||||
expect(blocked.success).toBe(false);
|
||||
if (blocked.success) throw new Error("unreachable");
|
||||
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
|
||||
|
||||
const acknowledged = await createTimeOff(prisma, {
|
||||
staffId,
|
||||
businessTimezone: business.timezone,
|
||||
input: { startDate: monday, endDate: monday, reason: "Day off" },
|
||||
acknowledgeConflicts: true,
|
||||
});
|
||||
expect(acknowledged.success).toBe(true);
|
||||
});
|
||||
|
||||
it("a partial-day block only conflicts with appointments inside its own window", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
|
||||
|
||||
// Appointment at 9am — outside the 12-1pm block below.
|
||||
await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
|
||||
endAt: wallClockToInstant(monday, 9 * 60 + 30, business.timezone),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId,
|
||||
businessTimezone: business.timezone,
|
||||
input: { startDate: monday, endDate: monday, startMinutes: 12 * 60, endMinutes: 13 * 60, reason: "Meeting" },
|
||||
});
|
||||
expect(result.success).toBe(true); // no conflict — the 9am appointment is outside the blocked window
|
||||
});
|
||||
|
||||
it("returns a graceful failure — not a throw — when a staff member tries to update another staff member's time off", async () => {
|
||||
const { staffId: owner } = await createTestStaff(business);
|
||||
const { staffId: intruder } = await createTestStaff(business);
|
||||
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
|
||||
const created = await createTimeOff(prisma, {
|
||||
staffId: owner,
|
||||
businessTimezone: business.timezone,
|
||||
input: { startDate: monday, endDate: monday, reason: "Day off" },
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
const result = await updateTimeOff(prisma, {
|
||||
timeOffId: created.timeOff.id,
|
||||
staffId: intruder,
|
||||
businessTimezone: business.timezone,
|
||||
input: { startDate: monday, endDate: monday, reason: "Hijacked" },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) throw new Error("unreachable");
|
||||
expect(result.validation.issues[0]?.code).toBe("PERMISSION_DENIED");
|
||||
});
|
||||
|
||||
it("returns {success:false} — not a throw — when a staff member tries to delete another staff member's time off", async () => {
|
||||
const { staffId: owner } = await createTestStaff(business);
|
||||
const { staffId: intruder } = await createTestStaff(business);
|
||||
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
|
||||
const created = await createTimeOff(prisma, {
|
||||
staffId: owner,
|
||||
businessTimezone: business.timezone,
|
||||
input: { startDate: monday, endDate: monday, reason: "Day off" },
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
const result = await deleteTimeOff(prisma, { timeOffId: created.timeOff.id, staffId: intruder });
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
const stillExists = await prisma.timeOff.findUnique({ where: { id: created.timeOff.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import {
|
||||
StaffPermissionDeniedError,
|
||||
assertOwnsResource,
|
||||
validateBreakInput,
|
||||
validateTimeOffInput,
|
||||
validateWorkingHoursInput,
|
||||
} from "../staff.service";
|
||||
|
||||
describe("assertOwnsResource", () => {
|
||||
it("does nothing when the acting staff member owns the resource", () => {
|
||||
expect(() => assertOwnsResource("staff-1", "staff-1")).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws StaffPermissionDeniedError when they don't match", () => {
|
||||
expect(() => assertOwnsResource("staff-1", "staff-2")).toThrow(StaffPermissionDeniedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateWorkingHoursInput", () => {
|
||||
it("accepts a day marked off with no times", () => {
|
||||
const result = validateWorkingHoursInput([{ dayOfWeek: Weekday.MONDAY, isOff: true }]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("requires start and end minutes for a working day", () => {
|
||||
const result = validateWorkingHoursInput([{ dayOfWeek: Weekday.MONDAY, isOff: false }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
|
||||
it("rejects start >= end", () => {
|
||||
const result = validateWorkingHoursInput([
|
||||
{ dayOfWeek: Weekday.TUESDAY, isOff: false, startMinutes: 600, endMinutes: 600 },
|
||||
]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
|
||||
});
|
||||
|
||||
it("rejects times outside a single day", () => {
|
||||
const result = validateWorkingHoursInput([
|
||||
{ dayOfWeek: Weekday.WEDNESDAY, isOff: false, startMinutes: 0, endMinutes: 25 * 60 },
|
||||
]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
|
||||
});
|
||||
|
||||
it("accepts a valid full week", () => {
|
||||
const result = validateWorkingHoursInput([
|
||||
{ dayOfWeek: Weekday.MONDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 },
|
||||
{ dayOfWeek: Weekday.SUNDAY, isOff: true },
|
||||
]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateBreakInput", () => {
|
||||
const base = { label: "Lunch", startMinutes: 12 * 60, endMinutes: 13 * 60, days: [Weekday.MONDAY] };
|
||||
|
||||
it("accepts a valid break", () => {
|
||||
expect(validateBreakInput(base).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty label", () => {
|
||||
const result = validateBreakInput({ ...base, label: " " });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
|
||||
it("rejects no days selected", () => {
|
||||
const result = validateBreakInput({ ...base, days: [] });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("NO_DAYS_SELECTED");
|
||||
});
|
||||
|
||||
it("rejects start >= end", () => {
|
||||
const result = validateBreakInput({ ...base, startMinutes: 13 * 60, endMinutes: 13 * 60 });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTimeOffInput", () => {
|
||||
it("accepts a whole-day range with no times", () => {
|
||||
const result = validateTimeOffInput({ startDate: "2026-08-10", endDate: "2026-08-12" });
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects start date after end date", () => {
|
||||
const result = validateTimeOffInput({ startDate: "2026-08-12", endDate: "2026-08-10" });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
|
||||
it("rejects providing only one of startMinutes/endMinutes", () => {
|
||||
const result = validateTimeOffInput({
|
||||
startDate: "2026-08-10",
|
||||
endDate: "2026-08-10",
|
||||
startMinutes: 600,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
|
||||
it("rejects a partial-day block spanning more than one day", () => {
|
||||
const result = validateTimeOffInput({
|
||||
startDate: "2026-08-10",
|
||||
endDate: "2026-08-11",
|
||||
startMinutes: 600,
|
||||
endMinutes: 660,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
|
||||
it("rejects a partial-day block with start >= end", () => {
|
||||
const result = validateTimeOffInput({
|
||||
startDate: "2026-08-10",
|
||||
endDate: "2026-08-10",
|
||||
startMinutes: 660,
|
||||
endMinutes: 660,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
|
||||
});
|
||||
|
||||
it("accepts a valid partial-day block (Block Time)", () => {
|
||||
const result = validateTimeOffInput({
|
||||
startDate: "2026-08-10",
|
||||
endDate: "2026-08-10",
|
||||
startMinutes: 600,
|
||||
endMinutes: 660,
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,627 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { NotificationCategory, NotificationChannel, Weekday } from "@/generated/prisma/enums";
|
||||
import {
|
||||
calendarDateOf,
|
||||
calendarDateToUtcMidnight,
|
||||
endOfBusinessDay,
|
||||
minutesFromMidnight,
|
||||
startOfBusinessDay,
|
||||
weekdayOf,
|
||||
} from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { fail, ok } from "@/lib/validation-result";
|
||||
import type { ValidationResult } from "@/lib/validation-result";
|
||||
|
||||
/**
|
||||
* Everything about a staff member's own operational setup: profile fields
|
||||
* they're allowed to edit themselves, working hours, breaks, and time off
|
||||
* (including the one-off "Block Time" case — a TimeOff row with minutes
|
||||
* set, see schema.prisma). Deliberately does NOT touch availability
|
||||
* calculation or appointment status — those stay in AvailabilityService/
|
||||
* StaffAvailabilityService and AppointmentService respectively. This
|
||||
* service only manages the *inputs* those read; it's the write side of
|
||||
* the same data StaffAvailabilityService reads.
|
||||
*/
|
||||
|
||||
export type StaffServiceErrorCode =
|
||||
| "INVALID_INPUT"
|
||||
| "INVALID_TIME_RANGE"
|
||||
| "NO_DAYS_SELECTED"
|
||||
| "STAFF_NOT_FOUND"
|
||||
| "BREAK_NOT_FOUND"
|
||||
| "TIME_OFF_NOT_FOUND"
|
||||
| "APPOINTMENT_CONFLICTS"
|
||||
| "PERMISSION_DENIED";
|
||||
|
||||
export class StaffPermissionDeniedError extends Error {
|
||||
constructor(message = "You don't have permission to manage this staff member's schedule.") {
|
||||
super(message);
|
||||
this.name = "StaffPermissionDeniedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Every mutation here takes the acting staff member's own id and asserts the resource belongs to them — a staff member manages only their own schedule this phase (see docs/phase-5-summary.md, "Permission model"). */
|
||||
export function assertOwnsResource(resourceStaffId: string, actingStaffId: string): void {
|
||||
if (resourceStaffId !== actingStaffId) {
|
||||
throw new StaffPermissionDeniedError();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Profile ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getStaffProfileByUserId(prisma: PrismaClient, userId: string) {
|
||||
return prisma.staffProfile.findUnique({
|
||||
where: { userId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
|
||||
services: { include: { service: true } },
|
||||
business: { select: { id: true, name: true, timezone: true } },
|
||||
role: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface UpdateStaffProfileInput {
|
||||
staffId: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
image?: string;
|
||||
bio?: string;
|
||||
/** "Public visibility" in the brief — whether customers can find/book this barber online. Reuses the existing field rather than adding a duplicate flag. */
|
||||
isBookableOnline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the fields the brief explicitly allows a barber to change themselves
|
||||
* — service eligibility, business settings, and other staff accounts are
|
||||
* deliberately not accepted here (owner-only, out of scope this phase).
|
||||
*/
|
||||
export async function updateStaffProfile(
|
||||
prisma: PrismaClient,
|
||||
input: UpdateStaffProfileInput,
|
||||
): Promise<{ success: boolean }> {
|
||||
const staff = await prisma.staffProfile.findUnique({
|
||||
where: { id: input.staffId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!staff) return { success: false };
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: staff.userId },
|
||||
data: {
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.phone !== undefined ? { phone: input.phone || null } : {}),
|
||||
...(input.image !== undefined ? { image: input.image || null } : {}),
|
||||
},
|
||||
}),
|
||||
prisma.staffProfile.update({
|
||||
where: { id: input.staffId },
|
||||
data: {
|
||||
...(input.bio !== undefined ? { bio: input.bio || null } : {}),
|
||||
...(input.isBookableOnline !== undefined
|
||||
? { isBookableOnline: input.isBookableOnline }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Working hours ───────────────────────────────────────────────────────
|
||||
|
||||
export interface WorkingHoursDayInput {
|
||||
dayOfWeek: Weekday;
|
||||
isOff: boolean;
|
||||
startMinutes?: number;
|
||||
endMinutes?: number;
|
||||
}
|
||||
|
||||
export function validateWorkingHoursInput(
|
||||
days: WorkingHoursDayInput[],
|
||||
): ValidationResult<StaffServiceErrorCode> {
|
||||
for (const day of days) {
|
||||
if (day.isOff) continue;
|
||||
if (day.startMinutes === undefined || day.endMinutes === undefined) {
|
||||
return fail({
|
||||
code: "INVALID_INPUT",
|
||||
message: `${day.dayOfWeek}: start and end time are required for a working day.`,
|
||||
});
|
||||
}
|
||||
if (day.startMinutes < 0 || day.endMinutes > 24 * 60) {
|
||||
return fail({
|
||||
code: "INVALID_TIME_RANGE",
|
||||
message: `${day.dayOfWeek}: times must fall within a single day.`,
|
||||
});
|
||||
}
|
||||
if (day.startMinutes >= day.endMinutes) {
|
||||
return fail({
|
||||
code: "INVALID_TIME_RANGE",
|
||||
message: `${day.dayOfWeek}: start time must be before end time.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function getWorkingHours(prisma: PrismaClient, staffId: string) {
|
||||
return prisma.workingHours.findMany({ where: { staffId }, orderBy: { dayOfWeek: "asc" } });
|
||||
}
|
||||
|
||||
export async function setWorkingHours(
|
||||
prisma: PrismaClient,
|
||||
params: { staffId: string; days: WorkingHoursDayInput[] },
|
||||
): Promise<{ success: boolean; validation: ValidationResult<StaffServiceErrorCode> }> {
|
||||
const validation = validateWorkingHoursInput(params.days);
|
||||
if (!validation.valid) return { success: false, validation };
|
||||
|
||||
await prisma.$transaction(
|
||||
params.days.map((day) =>
|
||||
prisma.workingHours.upsert({
|
||||
where: { staffId_dayOfWeek: { staffId: params.staffId, dayOfWeek: day.dayOfWeek } },
|
||||
create: {
|
||||
staffId: params.staffId,
|
||||
dayOfWeek: day.dayOfWeek,
|
||||
isOff: day.isOff,
|
||||
startMinutes: day.isOff ? null : (day.startMinutes ?? null),
|
||||
endMinutes: day.isOff ? null : (day.endMinutes ?? null),
|
||||
},
|
||||
update: {
|
||||
isOff: day.isOff,
|
||||
startMinutes: day.isOff ? null : (day.startMinutes ?? null),
|
||||
endMinutes: day.isOff ? null : (day.endMinutes ?? null),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return { success: true, validation: ok() };
|
||||
}
|
||||
|
||||
// ── Breaks ──────────────────────────────────────────────────────────────
|
||||
|
||||
const APPOINTMENT_CONFLICT_INCLUDE = {
|
||||
services: { include: { service: true } },
|
||||
customer: true,
|
||||
} as const;
|
||||
|
||||
export interface BreakInput {
|
||||
label: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
days: Weekday[];
|
||||
}
|
||||
|
||||
export function validateBreakInput(input: BreakInput): ValidationResult<StaffServiceErrorCode> {
|
||||
if (!input.label.trim())
|
||||
return fail({ code: "INVALID_INPUT", message: "Give this break a label." });
|
||||
if (input.days.length === 0)
|
||||
return fail({ code: "NO_DAYS_SELECTED", message: "Select at least one day for this break." });
|
||||
if (
|
||||
input.startMinutes < 0 ||
|
||||
input.endMinutes > 24 * 60 ||
|
||||
input.startMinutes >= input.endMinutes
|
||||
) {
|
||||
return fail({
|
||||
code: "INVALID_TIME_RANGE",
|
||||
message: "Break start time must be before its end time.",
|
||||
});
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function listBreaks(prisma: PrismaClient, staffId: string) {
|
||||
return prisma.break.findMany({
|
||||
where: { staffId },
|
||||
include: { days: true },
|
||||
orderBy: { startMinutes: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Future, non-cancelled appointments that would fall inside a recurring break on any of `days`. Breaks recur indefinitely, so only future appointments are ever relevant — one from last month isn't a live conflict. */
|
||||
export async function findBreakConflicts(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
staffId: string;
|
||||
businessTimezone: string;
|
||||
days: Weekday[];
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
excludeBreakId?: string;
|
||||
},
|
||||
) {
|
||||
const appointments = await prisma.appointment.findMany({
|
||||
where: {
|
||||
staffId: params.staffId,
|
||||
status: { notIn: ["CANCELLED", "NO_SHOW"] },
|
||||
startAt: { gte: new Date() },
|
||||
},
|
||||
include: APPOINTMENT_CONFLICT_INCLUDE,
|
||||
orderBy: { startAt: "asc" },
|
||||
});
|
||||
|
||||
return appointments.filter((a) => {
|
||||
const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone));
|
||||
if (!params.days.includes(weekday)) return false;
|
||||
const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
|
||||
const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
|
||||
return aStart < params.endMinutes && aEnd > params.startMinutes;
|
||||
});
|
||||
}
|
||||
|
||||
export async function createBreak(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
staffId: string;
|
||||
businessTimezone: string;
|
||||
input: BreakInput;
|
||||
acknowledgeConflicts?: boolean;
|
||||
},
|
||||
) {
|
||||
const validation = validateBreakInput(params.input);
|
||||
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
|
||||
|
||||
if (!params.acknowledgeConflicts) {
|
||||
const conflicts = await findBreakConflicts(prisma, {
|
||||
staffId: params.staffId,
|
||||
businessTimezone: params.businessTimezone,
|
||||
...params.input,
|
||||
});
|
||||
if (conflicts.length > 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
conflicts,
|
||||
validation: fail({
|
||||
code: "APPOINTMENT_CONFLICTS",
|
||||
message: "This break overlaps existing appointments.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const created = await prisma.break.create({
|
||||
data: {
|
||||
staffId: params.staffId,
|
||||
label: params.input.label,
|
||||
startMinutes: params.input.startMinutes,
|
||||
endMinutes: params.input.endMinutes,
|
||||
days: { create: params.input.days.map((dayOfWeek) => ({ dayOfWeek })) },
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true as const, break: created, conflicts: [], validation: ok() };
|
||||
}
|
||||
|
||||
export async function updateBreak(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
breakId: string;
|
||||
staffId: string;
|
||||
businessTimezone: string;
|
||||
input: BreakInput;
|
||||
acknowledgeConflicts?: boolean;
|
||||
},
|
||||
) {
|
||||
const existing = await prisma.break.findUnique({ where: { id: params.breakId } });
|
||||
if (!existing)
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({ code: "BREAK_NOT_FOUND" as const, message: "Break not found." }),
|
||||
conflicts: [],
|
||||
};
|
||||
if (existing.staffId !== params.staffId) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({ code: "PERMISSION_DENIED" as const, message: "You can only manage your own breaks." }),
|
||||
conflicts: [],
|
||||
};
|
||||
}
|
||||
|
||||
const validation = validateBreakInput(params.input);
|
||||
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
|
||||
|
||||
if (!params.acknowledgeConflicts) {
|
||||
const conflicts = await findBreakConflicts(prisma, {
|
||||
staffId: params.staffId,
|
||||
businessTimezone: params.businessTimezone,
|
||||
...params.input,
|
||||
excludeBreakId: params.breakId,
|
||||
});
|
||||
if (conflicts.length > 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
conflicts,
|
||||
validation: fail({
|
||||
code: "APPOINTMENT_CONFLICTS",
|
||||
message: "This break overlaps existing appointments.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.breakDay.deleteMany({ where: { breakId: params.breakId } }),
|
||||
prisma.break.update({
|
||||
where: { id: params.breakId },
|
||||
data: {
|
||||
label: params.input.label,
|
||||
startMinutes: params.input.startMinutes,
|
||||
endMinutes: params.input.endMinutes,
|
||||
days: { create: params.input.days.map((dayOfWeek) => ({ dayOfWeek })) },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { success: true as const, conflicts: [], validation: ok() };
|
||||
}
|
||||
|
||||
export async function deleteBreak(
|
||||
prisma: PrismaClient,
|
||||
params: { breakId: string; staffId: string },
|
||||
): Promise<{ success: boolean }> {
|
||||
const existing = await prisma.break.findUnique({ where: { id: params.breakId } });
|
||||
if (!existing) return { success: false };
|
||||
if (existing.staffId !== params.staffId) return { success: false };
|
||||
await prisma.break.delete({ where: { id: params.breakId } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Time off (also represents one-off "Block Time" — see schema.prisma) ──
|
||||
|
||||
export interface TimeOffInput {
|
||||
startDate: CalendarDate;
|
||||
endDate: CalendarDate;
|
||||
reason?: string;
|
||||
/** Both set = a partial-day block ("Block Time"); both omitted = whole-day time off. */
|
||||
startMinutes?: number;
|
||||
endMinutes?: number;
|
||||
}
|
||||
|
||||
export function validateTimeOffInput(input: TimeOffInput): ValidationResult<StaffServiceErrorCode> {
|
||||
if (input.startDate > input.endDate) {
|
||||
return fail({
|
||||
code: "INVALID_INPUT",
|
||||
message: "Start date must be on or before the end date.",
|
||||
});
|
||||
}
|
||||
const hasStart = input.startMinutes !== undefined;
|
||||
const hasEnd = input.endMinutes !== undefined;
|
||||
if (hasStart !== hasEnd) {
|
||||
return fail({
|
||||
code: "INVALID_INPUT",
|
||||
message:
|
||||
"Provide both a start and end time for a partial-day block, or neither for a full day.",
|
||||
});
|
||||
}
|
||||
if (hasStart && hasEnd) {
|
||||
if (input.startDate !== input.endDate) {
|
||||
return fail({
|
||||
code: "INVALID_INPUT",
|
||||
message: "A specific-time block can only cover a single day.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
input.startMinutes! < 0 ||
|
||||
input.endMinutes! > 24 * 60 ||
|
||||
input.startMinutes! >= input.endMinutes!
|
||||
) {
|
||||
return fail({ code: "INVALID_TIME_RANGE", message: "Start time must be before end time." });
|
||||
}
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
/** Split by calendar date, not business time zone — @db.Date columns are compared as UTC midnight regardless of zone, see business-time.ts. */
|
||||
export async function listTimeOff(prisma: PrismaClient, staffId: string) {
|
||||
const rows = await prisma.timeOff.findMany({
|
||||
where: { staffId },
|
||||
orderBy: { startDate: "desc" },
|
||||
});
|
||||
const today = calendarDateToUtcMidnight(new Date().toISOString().slice(0, 10));
|
||||
return {
|
||||
upcoming: rows
|
||||
.filter((r) => r.endDate >= today)
|
||||
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime()),
|
||||
past: rows.filter((r) => r.endDate < today),
|
||||
};
|
||||
}
|
||||
|
||||
/** Time off is only ever checked against appointments, never against other time off — there is no "excludeTimeOffId" case because two time-off rows can't conflict with each other. */
|
||||
export async function findTimeOffConflicts(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
staffId: string;
|
||||
businessTimezone: string;
|
||||
startDate: CalendarDate;
|
||||
endDate: CalendarDate;
|
||||
startMinutes?: number;
|
||||
endMinutes?: number;
|
||||
},
|
||||
) {
|
||||
const rangeStart = startOfBusinessDay(params.startDate, params.businessTimezone);
|
||||
const rangeEnd = endOfBusinessDay(params.endDate, params.businessTimezone);
|
||||
|
||||
const appointments = await prisma.appointment.findMany({
|
||||
where: {
|
||||
staffId: params.staffId,
|
||||
status: { notIn: ["CANCELLED", "NO_SHOW"] },
|
||||
startAt: { lt: rangeEnd },
|
||||
endAt: { gt: rangeStart },
|
||||
},
|
||||
include: APPOINTMENT_CONFLICT_INCLUDE,
|
||||
orderBy: { startAt: "asc" },
|
||||
});
|
||||
|
||||
if (params.startMinutes === undefined || params.endMinutes === undefined) return appointments;
|
||||
|
||||
return appointments.filter((a) => {
|
||||
const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
|
||||
const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
|
||||
return aStart < params.endMinutes! && aEnd > params.startMinutes!;
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTimeOff(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
staffId: string;
|
||||
businessTimezone: string;
|
||||
input: TimeOffInput;
|
||||
acknowledgeConflicts?: boolean;
|
||||
},
|
||||
) {
|
||||
const validation = validateTimeOffInput(params.input);
|
||||
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
|
||||
|
||||
if (!params.acknowledgeConflicts) {
|
||||
const conflicts = await findTimeOffConflicts(prisma, {
|
||||
staffId: params.staffId,
|
||||
businessTimezone: params.businessTimezone,
|
||||
...params.input,
|
||||
});
|
||||
if (conflicts.length > 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
conflicts,
|
||||
validation: fail({
|
||||
code: "APPOINTMENT_CONFLICTS",
|
||||
message: "This time off overlaps existing appointments.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const created = await prisma.timeOff.create({
|
||||
data: {
|
||||
staffId: params.staffId,
|
||||
startDate: calendarDateToUtcMidnight(params.input.startDate),
|
||||
endDate: calendarDateToUtcMidnight(params.input.endDate),
|
||||
reason: params.input.reason,
|
||||
startMinutes: params.input.startMinutes ?? null,
|
||||
endMinutes: params.input.endMinutes ?? null,
|
||||
status: "APPROVED",
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true as const, timeOff: created, conflicts: [], validation: ok() };
|
||||
}
|
||||
|
||||
export async function updateTimeOff(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
timeOffId: string;
|
||||
staffId: string;
|
||||
businessTimezone: string;
|
||||
input: TimeOffInput;
|
||||
acknowledgeConflicts?: boolean;
|
||||
},
|
||||
) {
|
||||
const existing = await prisma.timeOff.findUnique({ where: { id: params.timeOffId } });
|
||||
if (!existing)
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({ code: "TIME_OFF_NOT_FOUND" as const, message: "Time off not found." }),
|
||||
conflicts: [],
|
||||
};
|
||||
if (existing.staffId !== params.staffId) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({ code: "PERMISSION_DENIED" as const, message: "You can only manage your own time off." }),
|
||||
conflicts: [],
|
||||
};
|
||||
}
|
||||
|
||||
const validation = validateTimeOffInput(params.input);
|
||||
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
|
||||
|
||||
if (!params.acknowledgeConflicts) {
|
||||
const conflicts = await findTimeOffConflicts(prisma, {
|
||||
staffId: params.staffId,
|
||||
businessTimezone: params.businessTimezone,
|
||||
...params.input,
|
||||
});
|
||||
if (conflicts.length > 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
conflicts,
|
||||
validation: fail({
|
||||
code: "APPOINTMENT_CONFLICTS",
|
||||
message: "This time off overlaps existing appointments.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.timeOff.update({
|
||||
where: { id: params.timeOffId },
|
||||
data: {
|
||||
startDate: calendarDateToUtcMidnight(params.input.startDate),
|
||||
endDate: calendarDateToUtcMidnight(params.input.endDate),
|
||||
reason: params.input.reason,
|
||||
startMinutes: params.input.startMinutes ?? null,
|
||||
endMinutes: params.input.endMinutes ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true as const, conflicts: [], validation: ok() };
|
||||
}
|
||||
|
||||
export async function deleteTimeOff(
|
||||
prisma: PrismaClient,
|
||||
params: { timeOffId: string; staffId: string },
|
||||
): Promise<{ success: boolean }> {
|
||||
const existing = await prisma.timeOff.findUnique({ where: { id: params.timeOffId } });
|
||||
if (!existing) return { success: false };
|
||||
if (existing.staffId !== params.staffId) return { success: false };
|
||||
// Only future time off is editable/removable — see the brief's "Edit or remove future time off".
|
||||
await prisma.timeOff.delete({ where: { id: params.timeOffId } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Notification preferences ───────────────────────────────────────────
|
||||
// UI only this phase — no real delivery. Every (category, channel) pair
|
||||
// defaults to enabled (matches the schema's `isEnabled @default(true)`),
|
||||
// so a user with no rows yet still sees a fully-populated, all-on grid.
|
||||
|
||||
const NOTIFICATION_CATEGORIES = Object.values(NotificationCategory);
|
||||
const NOTIFICATION_CHANNELS = Object.values(NotificationChannel);
|
||||
|
||||
export interface NotificationPreferenceCell {
|
||||
category: NotificationCategory;
|
||||
channel: NotificationChannel;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export async function getNotificationPreferences(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
): Promise<NotificationPreferenceCell[]> {
|
||||
const rows = await prisma.notificationPreference.findMany({ where: { userId } });
|
||||
const byKey = new Map(rows.map((r) => [`${r.category}:${r.channel}`, r.isEnabled]));
|
||||
|
||||
const cells: NotificationPreferenceCell[] = [];
|
||||
for (const category of NOTIFICATION_CATEGORIES) {
|
||||
for (const channel of NOTIFICATION_CHANNELS) {
|
||||
cells.push({ category, channel, isEnabled: byKey.get(`${category}:${channel}`) ?? true });
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
export async function setNotificationPreferences(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
updates: NotificationPreferenceCell[],
|
||||
): Promise<{ success: true }> {
|
||||
await prisma.$transaction(
|
||||
updates.map((u) =>
|
||||
prisma.notificationPreference.upsert({
|
||||
where: { userId_category_channel: { userId, category: u.category, channel: u.channel } },
|
||||
create: { userId, category: u.category, channel: u.channel, isEnabled: u.isEnabled },
|
||||
update: { isEnabled: u.isEnabled },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
Reference in new issue
Block a user