From ec5583f9723f7d7fae4541bb4ad669b7c1057614 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Mon, 3 Aug 2026 12:56:55 +0100 Subject: [PATCH] Phase 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner Administration is complete. Summary of this final stretch of work: Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB. Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres: Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation). Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres. 12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively. Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected. Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass. --- docs/phase-6-summary.md | 174 +++++++++ e2e/owner-journeys.spec.ts | 331 ++++++++++++++++ .../migration.sql | 55 +++ .../migration.sql | 5 + prisma/schema.prisma | 86 ++++- prisma/seed.ts | 236 ++++++++++++ src/app/(auth)/invite/accept/page.tsx | 51 +++ src/app/(owner)/layout.tsx | 7 +- .../(owner)/owner/appointments/[id]/page.tsx | 268 +++++++++++++ src/app/(owner)/owner/calendar/page.tsx | 310 +++++++++++++++ src/app/(owner)/owner/page.tsx | 188 ++++++++- src/app/(owner)/owner/services/page.tsx | 49 +++ .../owner/settings/booking-rules/page.tsx | 50 +++ .../(owner)/owner/settings/business/page.tsx | 61 +++ src/app/(owner)/owner/settings/hours/page.tsx | 34 ++ .../(owner)/owner/settings/loyalty/page.tsx | 77 ++++ src/app/(owner)/owner/settings/page.tsx | 56 +++ .../(owner)/owner/team/[id]/hours/page.tsx | 76 ++++ src/app/(owner)/owner/team/[id]/page.tsx | 82 ++++ src/app/(owner)/owner/team/add/page.tsx | 37 ++ src/app/(owner)/owner/team/page.tsx | 96 ++++- .../(staff)/staff/appointments/[id]/page.tsx | 23 +- .../appointments/[id]/reschedule/layout.tsx | 10 + .../appointments/[id]/reschedule/page.tsx | 111 ++++++ src/components/navigation/bottom-nav.tsx | 3 +- src/components/navigation/staff-shell.tsx | 23 +- .../navigation/staff-sidebar-nav.tsx | 22 +- .../auth/actions/accept-invitation.ts | 47 +++ .../components/accept-invitation-form.tsx | 69 ++++ src/features/auth/schemas.ts | 11 + .../authorization.integration.test.ts | 124 ++++++ .../owner/actions/appointment-action-set.ts | 24 ++ src/features/owner/actions/appointments.ts | 178 +++++++++ src/features/owner/actions/booking-rules.ts | 43 +++ src/features/owner/actions/business.ts | 121 ++++++ src/features/owner/actions/calendar.ts | 79 ++++ src/features/owner/actions/catalogue.ts | 144 +++++++ src/features/owner/actions/loyalty-config.ts | 61 +++ src/features/owner/actions/loyalty.ts | 58 +++ src/features/owner/actions/staff.ts | 230 +++++++++++ src/features/owner/authorization.ts | 25 ++ .../owner/components/add-staff-form.tsx | 117 ++++++ .../owner/components/booking-rules-form.tsx | 171 +++++++++ .../components/business-details-form.tsx | 206 ++++++++++ .../owner/components/catalogue-manager.tsx | 267 +++++++++++++ .../owner/components/loyalty-config-form.tsx | 216 +++++++++++ .../owner/components/opening-hours-form.tsx | 201 ++++++++++ .../components/owner-block-time-button.tsx | 127 ++++++ .../owner/components/redeem-reward-button.tsx | 94 +++++ .../owner/components/service-form-sheet.tsx | 112 ++++++ .../owner/components/staff-detail-panel.tsx | 282 ++++++++++++++ .../components/appointment-status-actions.tsx | 39 +- src/features/staff/components/date-strip.tsx | 10 +- .../staff/components/day-calendar.tsx | 5 +- .../staff/components/working-hours-form.tsx | 15 +- src/lib/audit/write-audit-log.ts | 30 ++ src/lib/navigation/staff-nav-items.ts | 22 +- .../business.service.integration.test.ts | 153 ++++++++ .../__tests__/business.service.test.ts | 73 ++++ src/services/business/business.service.ts | 259 +++++++++++++ ...vice-catalogue.service.integration.test.ts | 153 ++++++++ .../service-catalogue.service.test.ts | 70 ++++ .../catalogue/service-catalogue.service.ts | 322 ++++++++++++++++ .../loyalty/__tests__/loyalty-config.test.ts | 65 ++++ .../loyalty-owner.integration.test.ts | 248 ++++++++++++ src/services/loyalty/loyalty.service.ts | 301 ++++++++++++++- .../reporting/owner-reporting.service.ts | 272 +++++++++++++ .../__tests__/booking-rule.service.test.ts | 71 ++++ .../scheduling/booking-rule.service.ts | 72 ++++ .../__tests__/staff-owner.integration.test.ts | 244 ++++++++++++ .../staff/__tests__/staff.service.test.ts | 36 ++ .../staff/staff-invitation.service.ts | 103 +++++ src/services/staff/staff.service.ts | 362 +++++++++++++++++- 73 files changed, 8400 insertions(+), 53 deletions(-) create mode 100644 docs/phase-6-summary.md create mode 100644 e2e/owner-journeys.spec.ts create mode 100644 prisma/migrations/20260803070350_owner_administration/migration.sql create mode 100644 prisma/migrations/20260803071240_loyalty_free_service/migration.sql create mode 100644 src/app/(auth)/invite/accept/page.tsx create mode 100644 src/app/(owner)/owner/appointments/[id]/page.tsx create mode 100644 src/app/(owner)/owner/calendar/page.tsx create mode 100644 src/app/(owner)/owner/services/page.tsx create mode 100644 src/app/(owner)/owner/settings/booking-rules/page.tsx create mode 100644 src/app/(owner)/owner/settings/business/page.tsx create mode 100644 src/app/(owner)/owner/settings/hours/page.tsx create mode 100644 src/app/(owner)/owner/settings/loyalty/page.tsx create mode 100644 src/app/(owner)/owner/settings/page.tsx create mode 100644 src/app/(owner)/owner/team/[id]/hours/page.tsx create mode 100644 src/app/(owner)/owner/team/[id]/page.tsx create mode 100644 src/app/(owner)/owner/team/add/page.tsx create mode 100644 src/app/owner/appointments/[id]/reschedule/layout.tsx create mode 100644 src/app/owner/appointments/[id]/reschedule/page.tsx create mode 100644 src/features/auth/actions/accept-invitation.ts create mode 100644 src/features/auth/components/accept-invitation-form.tsx create mode 100644 src/features/owner/__tests__/authorization.integration.test.ts create mode 100644 src/features/owner/actions/appointment-action-set.ts create mode 100644 src/features/owner/actions/appointments.ts create mode 100644 src/features/owner/actions/booking-rules.ts create mode 100644 src/features/owner/actions/business.ts create mode 100644 src/features/owner/actions/calendar.ts create mode 100644 src/features/owner/actions/catalogue.ts create mode 100644 src/features/owner/actions/loyalty-config.ts create mode 100644 src/features/owner/actions/loyalty.ts create mode 100644 src/features/owner/actions/staff.ts create mode 100644 src/features/owner/authorization.ts create mode 100644 src/features/owner/components/add-staff-form.tsx create mode 100644 src/features/owner/components/booking-rules-form.tsx create mode 100644 src/features/owner/components/business-details-form.tsx create mode 100644 src/features/owner/components/catalogue-manager.tsx create mode 100644 src/features/owner/components/loyalty-config-form.tsx create mode 100644 src/features/owner/components/opening-hours-form.tsx create mode 100644 src/features/owner/components/owner-block-time-button.tsx create mode 100644 src/features/owner/components/redeem-reward-button.tsx create mode 100644 src/features/owner/components/service-form-sheet.tsx create mode 100644 src/features/owner/components/staff-detail-panel.tsx create mode 100644 src/lib/audit/write-audit-log.ts create mode 100644 src/services/business/__tests__/business.service.integration.test.ts create mode 100644 src/services/business/__tests__/business.service.test.ts create mode 100644 src/services/business/business.service.ts create mode 100644 src/services/catalogue/__tests__/service-catalogue.service.integration.test.ts create mode 100644 src/services/catalogue/__tests__/service-catalogue.service.test.ts create mode 100644 src/services/catalogue/service-catalogue.service.ts create mode 100644 src/services/loyalty/__tests__/loyalty-config.test.ts create mode 100644 src/services/loyalty/__tests__/loyalty-owner.integration.test.ts create mode 100644 src/services/reporting/owner-reporting.service.ts create mode 100644 src/services/scheduling/__tests__/booking-rule.service.test.ts create mode 100644 src/services/scheduling/booking-rule.service.ts create mode 100644 src/services/staff/__tests__/staff-owner.integration.test.ts create mode 100644 src/services/staff/staff-invitation.service.ts diff --git a/docs/phase-6-summary.md b/docs/phase-6-summary.md new file mode 100644 index 0000000..8320019 --- /dev/null +++ b/docs/phase-6-summary.md @@ -0,0 +1,174 @@ +# Phase 6 — Owner Administration: Summary + +The complete owner administration experience, in the same application as the customer and barber experiences: dashboard, all-staff calendar, business details/hours, staff management and invitations, roles and service eligibility, service categories and services, booking rules, loyalty programme configuration and reward redemption, and business-wide settings. No separate owner app — owner is a permission on a staff account (the seeded owner, Marcus Reed, is also a working barber), reusing the existing auth/permission system, the `(staff)`-pattern shell, and the design system throughout. Four new focused domain services were added (`BusinessService`, `ServiceCatalogueService`, a small `booking-rule.service.ts`, and `OwnerReportingService`); everything else extends `StaffService`/`LoyaltyService` or is reused as-is. + +--- + +## 1. What was built + +- **Owner Dashboard** (`/owner`) — real, data-backed: today's appointment totals by status, staff working today, estimated occupancy (clearly labelled as an estimate, no invented revenue), popular services (last 30 days), recent loyalty activity, customers with an available reward, and configuration warnings (no opening hours, staff with no working hours, a service with no eligible active staff, an incomplete-but-enabled loyalty programme, time-off/appointment conflicts). +- **All-Staff Calendar** (`/owner/calendar`) — Day and Agenda views, staff filter pills, current-time indicator, reuses the exact same `DayCalendar`/`DateStrip`/`AppointmentCard` components the barber calendar uses (parametrized, not forked). Owner can view any appointment, create one (via the existing `/staff/book` flow — never duplicated), block time for any staff member, and reschedule/reassign. +- **Appointment Reassignment** — "Reschedule / Reassign" on the owner appointment details page reuses `BookingService.rescheduleBooking`'s existing (already staffId-aware) validation pipeline; no new domain logic was needed. +- **Owner Appointment Access** (`/owner/appointments/[id]`) — view/update status, reschedule/reassign, apply a loyalty reward, book on behalf of a customer, view customer notes — via owner-specific authorization helpers, kept separate from (not weakening) barber authorization. +- **Business Details** (`/owner/settings/business`) — name/logo/description/address/contact/social links/timezone/currency/locale/policies, backed by `Business`/`BusinessLocation`/`BusinessSettings`. +- **Opening Hours** (`/owner/settings/hours`) — per-weekday enable/times, "copy Monday to all days", conflict review before saving (shows affected future appointments, never auto-cancels, requires explicit "Save Anyway"). +- **Staff Management** (`/owner/team`, `/owner/team/[id]`) — search/add/view/deactivate/reactivate, role assignment, service eligibility, working-hours edit-on-behalf-of. Deactivation is conflict-reviewed and last-owner-protected. +- **Add Staff / Invitations** (`/owner/team/add`, `/invite/accept`) — creates a fully usable staff profile immediately, plus a `StaffInvitation` row surfaced as a dev-safe copy-link (no real email delivery this phase). +- **Roles and Permissions** — fixed built-in Owner/Barber roles (schema supports more); last-active-owner protected against self-removal/self-deactivation. +- **Staff Service Eligibility** — per-staff checklist of active services, conflict-reviewed when removing a service with future bookings. +- **Service Categories and Services** (`/owner/services`) — create/rename/reorder (up/down buttons, not drag-and-drop)/activate-deactivate/delete-if-unused, assign eligible staff, configure online-booking/buffers. Money handled as strings end-to-end, never a JS `number`. +- **Booking Rules** (`/owner/settings/booking-rules`) — minimum notice, max advance window, cancellation/reschedule windows, slot interval, buffers, per-day and per-staff-per-day caps, guest booking, barber selection, approval requirement. +- **Loyalty Configuration** (`/owner/settings/loyalty`) — enable/disable, stamps required, reward type (percentage/fixed/free-service) and value, eligible services/staff, expiry, auto-apply, card appearance, terms. +- **Loyalty Reward Redemption** (new this phase — Phase 5 deliberately left it unbuilt) — apply an AVAILABLE reward to an eligible appointment, atomically marking it REDEEMED, deducting stamps, and recording a discount on the appointment; available to both Owner and Barber (it's an operational action, not owner-only configuration). +- **Business Settings** — currency/locale/timezone/policies via the existing structured `BusinessSettings` model (no unstructured JSON blob). +- **Owner Settings Hub** (`/owner/settings`) — links to all the above, plus "Switch to Barber Tools" and the existing personal notification-preferences page (reused, not duplicated). +- **Audit Logging** — staff added/deactivated/role-changed/services-changed, business profile/hours changed, booking rules changed, loyalty config changed, reward redeemed, appointment reassigned — all written to the pre-existing (previously-unused) `AuditLog` table. + +## 2. Routes created + +Inside `(owner)` (persistent nav via the shared `StaffShell`, parametrized with `OWNER_NAV_ITEMS`): +`/owner`, `/owner/calendar`, `/owner/appointments/[id]`, `/owner/team`, `/owner/team/add`, `/owner/team/[id]`, `/owner/team/[id]/hours`, `/owner/services`, `/owner/settings`, `/owner/settings/business`, `/owner/settings/hours`, `/owner/settings/booking-rules`, `/owner/settings/loyalty`. + +Outside `(owner)` (own fixed-bottom-bar UI, same route-group-splitting pattern Phase 4/5 established for `/book` and `/staff/appointments/[id]/reschedule`): +`/owner/appointments/[id]/reschedule`. + +Public (inside `(auth)` for its shared centered-card shell, not staff/owner-gated): +`/invite/accept`. + +## 3. Components + +**New, owner-scoped** (`src/features/owner/components/`): `owner-block-time-button.tsx`, `redeem-reward-button.tsx`, `business-details-form.tsx`, `opening-hours-form.tsx`, `add-staff-form.tsx`, `staff-detail-panel.tsx`, `catalogue-manager.tsx`, `service-form-sheet.tsx`, `booking-rules-form.tsx`, `loyalty-config-form.tsx`. + +**New, shared auth** (`src/features/auth/components/accept-invitation-form.tsx`). + +**Generalized, not forked** (the same dependency-injection pattern Phase 5 used for `ServiceSelector.onContinue`): +- `AppointmentStatusActions` gained an optional `actions?: AppointmentActionSet` prop (default = staff Server Actions); the owner appointment page injects the owner variant instead of forking the component. +- `WorkingHoursForm` gained an optional `onSave` prop so `/owner/team/[id]/hours` injects `updateStaffWorkingHoursAction` instead of the self-service action. +- `DayCalendar` gained an optional `appointmentHrefBase` prop so the same component links to `/owner/appointments/[id]` or `/staff/appointments/[id]` depending on context. +- `DateStrip` gained an optional `extraParams` prop so the owner calendar's `?staffId=` filter survives date navigation. +- `StaffShell`/`StaffSidebarNav`/`BottomNav` all gained an `items` prop (default `STAFF_NAV_ITEMS`); the owner layout passes `OWNER_NAV_ITEMS` instead. `StaffSidebarNav` additionally gained an optional `footerLink` prop for the owner's "Barber Tools" back-link — explicitly one shared nav component, not competing owner/staff variants. + +## 4. Domain services + +**New:** +- **`BusinessService`** (`src/services/business/business.service.ts`) — business profile/location/settings CRUD, opening-hours validation and conflict-checked read/write. Deliberately excludes booking rules (scheduling policy) and loyalty (its own service) — see its own file header. +- **`ServiceCatalogueService`** (`src/services/catalogue/service-catalogue.service.ts`) — categories and services CRUD, reordering, usage-conflict lookup. Staff eligibility stays in `StaffService` (it's staff-scoped data, not catalogue data). +- **`booking-rule.service.ts`** (`src/services/scheduling/booking-rule.service.ts`) — read/write and validation for the `BookingRule` row itself. A new sibling file rather than an addition to `business-rules.service.ts`, because that file is deliberately pure/Prisma-free (its own header says so) and needs to stay that way to remain cheap to unit test; this file owns the one thing that one doesn't — loading and persisting the configuration an owner actually edits. +- **`OwnerReportingService`** (`src/services/reporting/owner-reporting.service.ts`) — read-only dashboard aggregation (`getDashboardSummary`, `getConfigurationWarnings`). Exists only because the dashboard's queries are genuinely more than "call one existing service and render it"; not a general owner data-access layer, and no other feature imports from it. +- **`StaffInvitationService`** (`src/services/staff/staff-invitation.service.ts`) — invitation token generation/validation/acceptance. Kept out of `StaffService` deliberately (acceptance tracking is a distinct concern from the staff profile itself, which is fully usable the moment it's created). + +**Extended:** +- **`StaffService`** — owner-facing additions: `listStaffForBusiness`, `getStaffDetailById`, `listRolesForBusiness`, `createStaffProfile`, `updateStaffAsOwner`, `deactivateStaff`/`reactivateStaff` (both last-owner-protected), `setStaffServiceEligibility`, all with matching conflict-finder functions (`findStaffDeactivationConflicts`, `findServiceEligibilityConflicts`). +- **`LoyaltyService`** — programme configuration (`getProgrammeConfig`, `updateProgrammeConfig`, `setEligibleServices`, `setEligibleStaff`) and reward redemption (`listEligibleAppointmentsForReward`, `redeemReward`). + +**Reused as-is:** `BookingService.rescheduleBooking` (for reassignment), `AppointmentService`, `AvailabilityService`, `StaffAvailabilityService`, `BusinessRulesService` (evaluation logic only), `CustomerService`. + +**New (feature-layer, not domain services):** `src/features/owner/authorization.ts` (`assertOwnerPermission`, mirroring `src/features/staff/authorization.ts`), `src/lib/audit/write-audit-log.ts`. + +## 5. Schema changes + +Two migrations: + +**`20260803070350_owner_administration`:** +- `Permission` enum gained `MANAGE_ALL_APPOINTMENTS` and `VIEW_REPORTING`. +- `Appointment.discountAmount Decimal? @db.Decimal(10,2)` — the effect of a redeemed loyalty reward, kept separate from `priceAtBooking`/`durationAtBookingMinutes` snapshots so those never need to change. +- New `StaffInvitation` model (token, email, expiresAt, acceptedAt, invitedByUserId) — one per `StaffProfile`, opaque DB-validated token (not HMAC-signed like the booking-draft cookie, since it's a single-use server-validated lookup, not something a client needs to verify offline). +- `LoyaltyReward` gained a real FK for `appliedToAppointment` (was already a plain string column) plus `redeemedByStaffId`/`redeemedByStaff`. + +**`20260803071240_loyalty_free_service`:** +- `LoyaltyProgramme.freeServiceId`/`freeService` — which service is free for a `FREE_SERVICE` reward. A genuine schema gap: the existing `eligibleServices` list serves a different purpose (which services *earn* stamps), not "which service the reward covers." + +Both applied via `npx prisma migrate dev` against a real Postgres, then `npx prisma generate`; seed and tests updated to match. + +**Reviewed and deliberately not added:** a `Business.isPubliclyVisible` toggle (no consumer/enforcement point yet in a single-business app), a dedicated "business exceptional closures" table (the brief said "where appropriate"; opening-hours conflict review already covers the common case). + +## 6. Dashboard metric approach + +`getDashboardSummary` composes, for a given date: `listAppointments` (existing service) filtered/counted by status for the totals row; `WorkingHours`/`TimeOff` for "staff working today" vs "staff on time off"; occupancy as `bookedMinutes / availableMinutes` across staff actually working that day, rounded to a percent and always rendered with the label "an estimate, not a financial figure" — never a revenue figure, since the brief explicitly warned against inventing one. Popular services aggregates `AppointmentService` rows for `COMPLETED` appointments in the last 30 days, in memory (not a SQL `GROUP BY`) since the dataset size doesn't warrant it. Configuration warnings (`getConfigurationWarnings`) are a separate, independently-callable function: no opening hours at all, a staff member with no working day set, an active service with no active eligible staff, an enabled-but-incomplete loyalty programme (reusing `validateProgrammeConfig` rather than re-implementing the check), and upcoming time-off that overlaps an existing appointment. + +## 7. All-staff calendar approach + +The owner calendar page is new, but it renders zero new calendar UI: it reuses `DayCalendar` (one column per staff member, or a single column when filtered) and `AppointmentCard` (agenda view) exactly as the barber calendar does, just fetching `listAppointments` for the whole business instead of one staff member and looping columns. The staff filter and Day/Agenda toggle are plain ``s carrying query params (`?date=&view=&staffId=`), so the page stays a Server Component with no client-side calendar state to keep in sync. "New" links straight to `/staff/book` (already lets any signed-in staff member, owner included, pick any eligible barber) rather than a duplicate `/owner/book` flow. + +## 8. Business/opening-hours behaviour + +`setOpeningHours` validates structurally first (open day needs both times, times within a single day, open before close), then — unless the caller passes `acknowledgeConflicts` — calls `findOpeningHoursConflicts`, which finds future, non-cancelled appointments that would fall outside the *proposed* hours (either on a day newly marked closed, or outside a still-open day's new window). If any exist, the save is rejected with the conflict list attached; the UI shows each affected appointment and offers "Go Back" or "Save Anyway." Saving never touches the appointments themselves — only `BusinessHours` rows, in one transaction, in an upsert per weekday. The customer-facing availability engine reads `BusinessHours` fresh on every request, so a save is reflected immediately with no cache to invalidate. + +## 9. Staff invitation/management approach + +`createStaffProfile` creates a fully usable `User` + `StaffProfile` + initial `StaffService` eligibility rows in one nested `create` — the profile can appear on the calendar and be booked immediately, it is never gated on invitation acceptance. `StaffInvitationService.createInvitation` separately creates a `StaffInvitation` row with a random 32-byte base64url token and a 7-day expiry; the owner UI surfaces it as a copy-link (`/invite/accept?token=...`) since there's no real email delivery this phase — documented as a deliberate dev-safe shortcut, not a production gap to hide. Accepting sets a password (bcrypt-hashed, mirroring `registerAction`), marks `emailVerified`/`acceptedAt`, and signs the new staff member in immediately. Deactivation (`deactivateStaff`) is conflict-reviewed exactly like opening hours (future appointments shown, "Deactivate Anyway" required) and last-owner-protected (see §14); it flips `StaffProfile.status` to `INACTIVE` and never deletes the row, preserving appointment history integrity (`Appointment.staff` is `onDelete: Restrict` for exactly this reason). + +## 10. Service/category management approach + +Categories and services share one conflict-review-free CRUD path for create/edit/reorder/activate-deactivate (these can never orphan an existing booking — a deactivated service or category simply stops appearing for *new* bookings), but deletion is different: `deleteCategory` refuses outright if the category still has any services (active or not), directing the owner to deactivate instead — deletion is only for genuinely empty, unused categories. Editing a service's price or duration only ever changes the live `Service` row; the historical `AppointmentService.priceAtBooking`/`durationAtBookingMinutes` snapshot on every past and already-confirmed appointment is never rewritten (verified by a dedicated integration test). Reordering is up/down buttons per item, not drag-and-drop, matching the brief's own accessibility note about keyboard alternatives. + +## 11. Booking-rule behaviour + +`updateBookingRule` validates every field's range (minimum notice ≥ 0, max advance ≥ 1 day, slot interval 5–120 minutes, per-day/per-staff-per-day caps ≥ 1 or unlimited) and upserts the single `BookingRule` row for the business. No conflict-review step exists for booking rules specifically — tightening a rule (e.g. requiring approval, shortening the cancellation window) changes how *future* bookings behave without altering any existing appointment's stored state, so there is nothing to show an affected-appointments list for; the customer booking flow reads the current `BookingRule` row fresh on every booking attempt, so a save takes effect on the very next booking (exercised directly in the E2E "require approval" journey, §16). + +## 12. Loyalty configuration/redemption approach + +`updateProgrammeConfig` validates only what's required *when enabled* (`validateProgrammeConfig`) — a disabled, half-configured draft is allowed since the owner may still be working on it; enabling a `FREE_SERVICE` reward additionally checks the chosen service is active. `setEligibleServices`/`setEligibleStaff` replace their respective lists wholesale in a transaction (delete-all-then-recreate), matching the same pattern `setStaffServiceEligibility` uses. + +`redeemReward` is the one place `LoyaltyReward.status`, `LoyaltyAccount.currentStamps`, and `Appointment.discountAmount` ever change together, inside a single `$transaction`. The status flip uses a conditional `updateMany` (`WHERE status = 'AVAILABLE'`) rather than a read-then-write, so two simultaneous redemption attempts can't both succeed — the loser sees `REWARD_NOT_AVAILABLE`, never a double deduction (covered by a dedicated integration test that redeems, then attempts a second redemption of the same reward). The discount is computed from the reward type (percentage of subtotal, fixed amount, or the price of the matching free-service line), clamped to `[0, subtotal]`, and written to `Appointment.discountAmount` — the original `priceAtBooking` snapshots are never touched, so the subtotal is always recoverable and the final total is always `subtotal - discountAmount`, never negative. A documented simplification: redemption reads the loyalty programme's *current* `stampsRequired`/`freeServiceId`, not a value snapshotted when the reward was earned — if the owner reconfigures the programme in between, redemption reflects the new configuration. + +## 13. Conflict-review behaviour + +One pattern, reused everywhere a change could strand an existing appointment: **validate → find affected future appointments → if any and not yet acknowledged, return them without writing anything → the UI shows each one and offers "Go Back" or an explicit "\[Verb] Anyway" → only that second call actually writes, and only ever the configuration being changed, never the appointments themselves → the same call also writes an audit log entry.** This exact shape now covers business opening hours (§8), staff deactivation (§9), and staff service-eligibility removal (§10) — all three share the "conflicts: []" / "acknowledgeConflicts" parameter shape and all three are exercised by an integration test asserting the blocked-then-Save-Anyway-then-appointment-still-intact sequence. Booking rules (§11) and loyalty configuration (§12) don't need this pattern, for the reasons given in their own sections. + +## 14. Permission and business-isolation rules + +The `(owner)` layout gates on `MANAGE_STAFF` as a coarse "is this person any kind of owner" shell check — but every individual Server Action re-derives the session and checks the *specific* permission that action needs (`MANAGE_BUSINESS_PROFILE`, `MANAGE_SERVICES`, `MANAGE_BOOKING_RULES`, `MANAGE_LOYALTY_PROGRAMME`, `MANAGE_ALL_APPOINTMENTS`, `VIEW_ALL_STAFF_APPOINTMENTS`, `VIEW_REPORTING`), via `assertOwnerPermission` → `hasPermission`, which re-queries the database every time rather than trusting the session/JWT — a permission change (or a demotion) takes effect on the very next request, not after the token expires (verified directly: see the integration test asserting a permission change is reflected immediately, §16). Every owner service function additionally re-scopes its query by `businessId` even though this is a single-business v1 app — explicit defense-in-depth, not dead code, matching the same discipline Phase 4/5 applied. **Last-owner protection**: "an owner" is defined as any staff member whose role holds `MANAGE_STAFF` (not a hardcoded role name, so a future custom role with that permission is automatically covered); both `deactivateStaff` and `updateStaffAsOwner` (role change) refuse the action if it would leave zero other active staff members holding `MANAGE_STAFF`. + +## 15. Audit logging + +`writeAuditLog(prisma, {actorUserId, action, entityType, entityId, metadata})` is called from every owner mutation Server Action (not from inside the domain services themselves, keeping the domain layer free of "who did this and why" concerns) with an action-specific name: `BUSINESS_PROFILE_UPDATED`, `STAFF_ADDED`, `STAFF_ROLE_CHANGED`/`STAFF_UPDATED`, `STAFF_DEACTIVATED`/`STAFF_REACTIVATED`, `STAFF_SERVICES_CHANGED`, `STAFF_WORKING_HOURS_CHANGED`, `BOOKING_RULES_UPDATED`, `LOYALTY_CONFIG_UPDATED`, `LOYALTY_REWARD_REDEEMED`, `APPOINTMENT_REASSIGNED` (only written when the staff actually changes, not on a plain reschedule). `metadata` is typed `Prisma.InputJsonValue` and only ever carries safe, already-public-to-the-owner values (names, ids, booleans) — no passwords, invitation tokens, or other secrets are ever passed in. + +## 16. Tests + +**Unit** (Vitest, no DB — 179 tests total, up from ~150 before this phase): pure validators for every new input shape — `validateBusinessProfileInput`/`validateOpeningHoursInput` (business.service.test.ts), `validateCategoryInput`/`validateServiceInput` including the decimal-price regex (service-catalogue.service.test.ts), `validateBookingRuleInput` including every range boundary (booking-rule.service.test.ts), `validateProgrammeConfig` for all three reward types and the enabled-vs-disabled leniency (loyalty-config.test.ts), and `validateCreateStaffInput` (appended to the existing `staff.service.test.ts`). + +**Integration** (Vitest, real throwaway Postgres — 86 tests total): `business.service.integration.test.ts` (opening-hours conflict detection + Save Anyway + the appointment stays untouched), `service-catalogue.service.integration.test.ts` (category-deletion-blocked-when-has-services, business isolation, and the historical-snapshot-preservation test described in §10), `staff-owner.integration.test.ts` (staff creation + duplicate-email rejection, last-owner protection on both deactivation and role change, deactivation/eligibility conflict review + Save Anyway), `loyalty-owner.integration.test.ts` (programme config create-then-update, free-service-must-be-active, eligible-services replace-wholesale, and the redemption suite: successful redemption's atomic discount/status/stamp effects, duplicate-redemption race prevention via the conditional `updateMany`, already-discounted-appointment rejection, wrong-customer rejection), and `authorization.integration.test.ts` (owner-vs-barber permission grants/denials against real `Role`/`RolePermission` rows, a live permission-change-takes-effect-immediately test, and business-isolation checks on `updateStaffAsOwner`/`deactivateStaff`/`deleteCategory` against a second business's data). + +**End-to-end** (Playwright, `e2e/owner-journeys.spec.ts`, 12 journeys, run serially): dashboard with real data and configuration warnings; all-staff calendar filter + Day/Agenda; business details edit + persistence; opening hours tightened + conflict shown + Save Anyway + appointment untouched; add a barber and get a dev invite link; change a barber's role and service eligibility (round-tripped); create a category and service, verified immediately on the customer `/book` page; require booking approval and verify a fresh customer booking lands `Pending Approval`; configure the loyalty programme; redeem a customer's available reward; a barber without `MANAGE_STAFF` is redirected away from `/owner`; the owner switches back to Barber Tools without losing the barber nav. Every journey that mutates shared configuration (business details, opening hours, booking rules, loyalty config) restores the original value at the end of the same test, matching the Phase 5 precedent; journey 10 (redeem reward) is a documented one-shot consumption of the seed's single AVAILABLE reward, same category as Phase 5's own booking journeys. + +Running them: +```bash +npm run test # unit, no DB — 179 tests +npm run test:integration # needs DATABASE_URL → a real Postgres — 86 tests +npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres — 27 journeys (6 Phase 4 + 9 Phase 5 + 12 Phase 6) +``` + +## 17. Manual verification + +- Signed in as the owner (Marcus Reed) and walked every new page — dashboard, all-staff calendar (day + agenda, staff filter), business details, opening hours, team list + staff detail (role/eligibility/deactivate), add-staff + invite-link, services & categories (add/edit/reorder/delete-blocked), booking rules, loyalty config, appointment details + redeem reward, reschedule/reassign — in a real browser against seeded data, at both mobile (375px) and desktop (1280px) viewports. +- Signed in as a barber (Leo Vance, no `MANAGE_STAFF`) and confirmed `/owner` redirects to `/staff` with no owner nav visible. +- Verified the desktop two-column treatment on Business Details, Booking Rules, and Loyalty Configuration (element rects confirmed genuinely side-by-side, not just visually similar in a screenshot), the Team page's 2-column card grid, the Staff Detail page's master-detail split, and the all-staff calendar's multi-column day view — and confirmed all of them still stack to a single column at 375px with no regression. +- `npx tsc --noEmit`, `npx eslint .`, and `npx next build` all clean on the final state. +- All three automated suites (179 unit / 86 integration / 27 E2E) passed together on one freshly created, migrated, and seeded database in a single run — the authoritative verification (see §19 for why a *repeated* run against the same database needs care). + +## 18. Assumptions made + +- **A single-business deployment**, same as every prior phase — no multi-business owner switching was built or implied. +- **"An owner" is a permission, not an account type** — Owner/Barber are the only two roles seeded, but every last-owner check is written against "does this role hold `MANAGE_STAFF`," so a future `Manager`/`Receptionist` role with a subset of owner permissions fits without a schema or logic change. +- **Redemption eligibility is service-agnostic beyond `FREE_SERVICE`** — a percentage or fixed-amount reward can be applied to any of the customer's own appointments, not restricted to appointments containing "eligible" services (eligibility governs *earning* stamps, not which appointment a reward can be *applied to*). +- **No payments/deposits are modelled** — a redeemed reward's discount is recorded, but there is no payment capture, refund, or deposit-hold anywhere in this phase, matching the explicit scope boundary. +- **The dev invite-link flow is accepted as a permanent, documented dev-mode behaviour for this phase**, not a stopgap hidden from the summary — real delivery is explicitly out of scope (§20). + +## 19. Known limitations + +- **Working-hours editing has no conflict detection**, on both the self-service (Phase 5) and owner-on-behalf-of-staff (this phase) editors — unlike breaks, time off, business hours, staff deactivation, and service-eligibility removal, all of which do. A pre-existing Phase 5 gap this phase chose not to retroactively fix, given the size of the remaining Phase 6 scope; flagged again here rather than silently carried forward. +- **Owner-editable breaks/whole-day time off for a staff member were not built** — only partial-day "Block Time" (via `OwnerBlockTimeButton`) satisfies "add time off on behalf of staff" this phase; a staff member's own recurring breaks and whole-day time off can currently only be edited by that staff member themselves. +- **Staff service eligibility is edited only from the Staff Detail page**, not duplicated on the Services & Categories page (the brief mentions "assign eligible staff" under Services too) — one edit surface, avoiding two places that could drift out of sync. +- **"Requires staff approval to redeem" is informational text only** in the loyalty config UI — redemption is already gated behind the `MANAGE_LOYALTY_STAMPS` permission and there is no customer-initiated redemption request to approve against, so no separate approval-queue workflow exists. +- **A repeated run of the E2E suite against the same (non-reseeded) database is not fully idempotent**: journey 10 (redeem reward) consumes the seed's one AVAILABLE reward, and one seed appointment (`appt-today-checkedin`, timed via `minutesFromNow` to reliably exercise `selectCurrentAppointment`'s CHECKED_IN tier) can occasionally start a few minutes before the business's 10:00 opening depending on exactly when the seed happens to run, which the opening-hours conflict test now handles defensively rather than assuming a clean save — this is a real, if narrow, characteristic of time-relative seed fixtures re-run without a fresh database, same category of issue Phase 5's own summary flagged (§13 there); it does **not** affect a single authoritative run against a freshly seeded database, which is how CI should always run it. +- **Notification delivery, payments, deposits, advanced financial reporting, payroll, multi-business/multi-location, PWA, and offline** remain entirely out of scope, per the brief's own boundary. + +## 20. Review items for Phase 7 + +- Decide whether owner-editable breaks and whole-day time off (on behalf of staff) should be built, or whether "Block Time" (partial-day) remains the only owner-side lever indefinitely. +- Decide whether working-hours edits (both self-service and owner-on-behalf-of) should gain the same conflict-review treatment as breaks/time-off/business-hours, closing the gap flagged in §19. +- Real notification delivery (email/SMS/push) is still entirely unbuilt — both the Phase 5 preference grid and this phase's invite-link workaround are waiting on it. +- Payments/deposits and any real financial reporting beyond the operational dashboard estimate are unstarted; `Appointment.discountAmount` exists and is populated correctly, but nothing yet reconciles it against an actual payment. +- If a future phase adds custom (non-Owner/Barber) roles with a role editor, the "is an owner" check (role holds `MANAGE_STAFF`) and the fixed built-in-roles assumption in `listRolesForBusiness`/`AddStaffForm` should be revisited together. +- The E2E suite's reliance on a freshly seeded database for a fully clean run (§19) has no CI wiring yet to provision one automatically — same open item Phase 4/5 already flagged, still unresolved. diff --git a/e2e/owner-journeys.spec.ts b/e2e/owner-journeys.spec.ts new file mode 100644 index 0000000..4c2708a --- /dev/null +++ b/e2e/owner-journeys.spec.ts @@ -0,0 +1,331 @@ +import { expect, test } from "@playwright/test"; +import { Client } from "pg"; +import { SEEDED, completeBookingFlow, signIn, uniqueEmail } from "./helpers"; + +/** + * The 12 owner-administration journeys required by the Phase 6 brief. Same + * principles as staff-journeys.spec.ts: real forms against the real dev + * server and seeded Postgres, `test.describe.serial` because several + * journeys deliberately chain or share seeded fixtures (Marcus Reed is + * both the seeded owner *and* a working barber — see prisma/seed.ts), and + * every mutation a test makes to shared configuration (business details, + * opening hours, booking rules, loyalty config) is restored at the end of + * that same test so later journeys — and later re-runs — see the same + * starting state. The one exception is journey 10 (redeem reward), which + * consumes the seed's one-time AVAILABLE reward for Sam Osei; that's a + * deliberate one-shot fixture, same category as staff-journeys.spec.ts's + * own booking journeys. + */ + +test.describe.serial("owner administration journeys", () => { + test.afterAll(async () => { + const client = new Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + // Journey 7 creates a category/service with no deterministic id to + // upsert against on a re-run — clean up by name instead. + await client.query(`DELETE FROM "Service" WHERE name LIKE 'E2E Test Service%'`); + await client.query(`DELETE FROM "ServiceCategory" WHERE name LIKE 'E2E Test Category%'`); + // Journey 5 adds a real staff member (User+StaffProfile+StaffInvitation) with no + // deterministic id either — deleting the User cascades to the rest. + await client.query(`DELETE FROM "User" WHERE email LIKE 'e2e-new-barber-%@e2e-test.local'`); + await client.end(); + }); + + test("1. owner sees a real, data-backed dashboard with configuration warnings", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/owner"); + + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + // Seeded fixtures from prisma/seed.ts: Owen Clarke has no working hours, + // and "Kids Haircut (Under 12)" has no eligible active staff member. + await expect(page.getByText("Owen Clarke has no working hours set")).toBeVisible(); + await expect(page.getByText(/no active staff member is eligible to perform it/)).toBeVisible(); + await expect(page.getByText("Staff Today")).toBeVisible(); + // Scoped exact match — "Marcus Reed" can otherwise also match a Recent Loyalty + // Activity line naming him as the staff member who redeemed a reward. + await expect(page.getByText("Marcus Reed", { exact: true })).toBeVisible(); + await expect(page.getByText("Popular Services")).toBeVisible(); + }); + + test("2. owner views the all-staff calendar, filters by staff, and switches to agenda", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/owner/calendar"); + + await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Marcus Reed", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Leo Vance", exact: true })).toBeVisible(); + + // Filtering to one staff member narrows the day view to a single column. + await page.getByRole("link", { name: "Leo Vance", exact: true }).click(); + await expect(page).toHaveURL(/staffId=/); + // The single remaining day-view column is headed by a

with the staff member's exact name + // (the filter pill link has the same text, so scope to the paragraph specifically). + await expect(page.locator("p").getByText("Leo Vance", { exact: true })).toBeVisible(); + + await page.getByRole("link", { name: "Agenda", exact: true }).click(); + await expect(page).toHaveURL(/view=agenda/); + await expect(page.getByRole("link", { name: "Day", exact: true })).toBeVisible(); + + await page.getByRole("link", { name: "All Staff", exact: true }).click(); + await expect(page).not.toHaveURL(/staffId=/); + }); + + test("3. owner edits business details and the change persists", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/owner/settings/business"); + + await expect(page.getByRole("heading", { name: "Business Details" })).toBeVisible(); + const nameInput = page.getByLabel("Business Name"); + await expect(nameInput).toHaveValue("Blake Heritage Barbershop"); + + await nameInput.fill("Blake Heritage Barbershop (E2E edited)"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved — changes are live for customers immediately.")).toBeVisible(); + + await page.reload(); + await expect(page.getByLabel("Business Name")).toHaveValue("Blake Heritage Barbershop (E2E edited)"); + + // Restore the seeded name so later runs (and other journeys) see the original. + await page.getByLabel("Business Name").fill("Blake Heritage Barbershop"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved — changes are live for customers immediately.")).toBeVisible(); + }); + + test("4. owner tightens opening hours, reviews the appointment conflict, and saves anyway without cancelling it", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + + // Marcus and Leo each have a seeded future CONFIRMED appointment (see + // prisma/seed.ts, appt-upcoming-confirmed / appt-leo-future). Those rows + // are upserted by a deterministic id, so on a database that already had + // them from an earlier seed run their date is whatever it was back + // then, not necessarily still "N days from today" — read the real date + // straight from Postgres instead of predicting it from wall-clock time. + const client = new Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + const { rows } = await client.query<{ id: string; start_at: Date; first_name: string; last_name: string }>( + `SELECT a.id, a."startAt" as start_at, c."firstName" as first_name, c."lastName" as last_name + FROM "Appointment" a JOIN "CustomerProfile" c ON c.id = a."customerId" + WHERE a.id LIKE '%-appt-upcoming-confirmed' OR a.id LIKE '%-appt-leo-future' + ORDER BY a."startAt" ASC`, + ); + await client.end(); + if (rows.length === 0) throw new Error("Seeded opening-hours conflict fixtures not found — check prisma/seed.ts"); + + // Prefer a fixture that doesn't fall on Sunday (already closed by default) so toggling it + // off is a genuine change; fall back to whichever fixture exists if only one does. + const candidate = + rows.find((r) => r.start_at.toLocaleDateString("en-GB", { weekday: "long" }) !== "Sunday") ?? rows[0]!; + const targetDay = candidate.start_at.toLocaleDateString("en-GB", { weekday: "long" }); + const expectedCustomer = `${candidate.first_name} ${candidate.last_name}`; + + await page.goto("/owner/settings/hours"); + await expect(page.getByRole("heading", { name: "Opening Hours" })).toBeVisible(); + + await page.getByRole("switch", { name: `${targetDay} open` }).click(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + + await expect(page.getByText(/would leave \d+ upcoming appointment/)).toBeVisible(); + await expect(page.getByText(expectedCustomer)).toBeVisible(); + + await page.getByRole("button", { name: "Save Anyway", exact: true }).click(); + await expect(page.getByText("Saved. The booking engine reflects these hours immediately.")).toBeVisible(); + + // The conflicting appointment itself is untouched — never silently cancelled. + await page.reload(); + await expect(page.getByRole("switch", { name: `${targetDay} open` })).toHaveAttribute("data-state", "unchecked"); + + // Restore the seeded default (open 10:00-19:00) so later runs see the same starting state. + // This save can itself surface an unrelated conflict — one of the seed's own "today" + // fixtures (see prisma/seed.ts) is timed relative to whenever the seed happened to run, + // and can coincidentally fall just before the business's 10:00 opening — so handle either + // outcome rather than assuming a clean save. + await page.getByRole("switch", { name: `${targetDay} open` }).click(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + const saveAnywayButton = page.getByRole("button", { name: "Save Anyway", exact: true }); + const savedStatus = page.getByText("Saved. The booking engine reflects these hours immediately."); + await expect(saveAnywayButton.or(savedStatus)).toBeVisible(); + if (await saveAnywayButton.isVisible()) { + await saveAnywayButton.click(); + } + await expect(savedStatus).toBeVisible(); + }); + + test("5. owner adds a new barber and gets a dev invite link", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/owner/team/add"); + + await expect(page.getByRole("heading", { name: "Add Staff" })).toBeVisible(); + const email = uniqueEmail("e2e-new-barber"); + const name = `E2E New Barber ${Date.now()}`; + await page.getByLabel("Name").fill(name); + await page.getByLabel("Email").fill(email); + await page.getByRole("button", { name: "Add Staff Member", exact: true }).click(); + + await expect(page.getByText(`${name} has been added.`)).toBeVisible(); + await expect(page.getByText(/\/invite\/accept\?token=/)).toBeVisible(); + + await page.getByRole("button", { name: "Done", exact: true }).click(); + await expect(page).toHaveURL(/\/owner\/team$/); + await page.getByPlaceholder("Search by name or email").fill(name); + await page.keyboard.press("Enter"); + await expect(page.getByRole("link", { name: new RegExp(name) })).toBeVisible(); + }); + + test("6. owner changes a barber's role and service eligibility", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/owner/team?q=Jamie"); + await page.getByRole("link", { name: /Jamie Wallace/ }).click(); + + await expect(page.getByRole("heading", { name: "Jamie Wallace", level: 2 })).toBeVisible(); + + // Round-trip the role: Barber -> Owner -> Barber, confirming the save + // path (and last-owner protection isn't a factor for a non-owner). + const roleSelect = page.getByLabel("Role"); + await expect(roleSelect).toHaveValue(/.+/); + await roleSelect.selectOption({ label: "Owner" }); + await page.getByRole("button", { name: "Save Role", exact: true }).click(); + await expect(page.getByText("Saved.").first()).toBeVisible(); + + await page.reload(); + await page.getByLabel("Role").selectOption({ label: "Barber" }); + await page.getByRole("button", { name: "Save Role", exact: true }).click(); + await expect(page.getByText("Saved.").first()).toBeVisible(); + + // Jamie isn't seeded eligible for Haircut & Style — add it, save, then remove it again. + const haircutCheckbox = page.getByRole("checkbox", { name: SEEDED.services.haircut }); + await expect(haircutCheckbox).not.toBeChecked(); + await haircutCheckbox.check(); + await page.getByRole("button", { name: "Save Services", exact: true }).click(); + await expect(page.getByText("Saved.").first()).toBeVisible(); + + await page.reload(); + await page.getByRole("checkbox", { name: SEEDED.services.haircut }).uncheck(); + await page.getByRole("button", { name: "Save Services", exact: true }).click(); + await expect(page.getByText("Saved.").first()).toBeVisible(); + }); + + test("7. owner creates a service category and service, visible immediately on the customer booking flow", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/owner/services"); + await expect(page.getByRole("heading", { name: "Services", exact: true })).toBeVisible(); + + const categoryName = `E2E Test Category ${Date.now()}`; + await page.getByPlaceholder("New category name").fill(categoryName); + await page.getByRole("button", { name: "Add Category", exact: true }).click(); + await expect(page.getByRole("heading", { name: categoryName, level: 3 })).toBeVisible(); + + const categorySection = page.locator("section").filter({ has: page.getByRole("heading", { name: categoryName, level: 3 }) }); + await categorySection.getByRole("button", { name: "Add Service", exact: true }).click(); + + const serviceName = `E2E Test Service ${Date.now()}`; + const sheet = page.getByRole("dialog"); + await sheet.getByLabel("Name").fill(serviceName); + await sheet.getByLabel("Duration (min)").fill("30"); + await sheet.getByLabel("Price").fill("15.00"); + await sheet.getByRole("button", { name: "Add Service", exact: true }).click(); + + await expect(page.getByText(serviceName)).toBeVisible(); + + // The new service is immediately bookable by customers. + await page.goto("/book"); + await expect(page.getByRole("button", { name: new RegExp(`^${serviceName}`) })).toBeVisible(); + }); + + test("8. owner requires booking approval, and a new customer booking reflects it immediately", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/owner/settings/booking-rules"); + await expect(page.getByRole("heading", { name: "Booking Rules" })).toBeVisible(); + + const approvalSwitch = page.getByRole("switch", { name: "Require approval for new bookings" }); + await expect(approvalSwitch).toHaveAttribute("data-state", "unchecked"); + await approvalSwitch.click(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText(/Saved — the customer booking flow uses these rules immediately/)).toBeVisible(); + + // A fresh guest booking now lands as PENDING, not CONFIRMED. Sign out first — + // signed in as Marcus, /book/details books as him rather than showing guest fields. + // Wait for the save's background requests to fully settle before clearing cookies: + // otherwise a still-in-flight response can re-set the session cookie right after + // it's cleared (a real race, confirmed by reproducing it outside Playwright too). + await page.waitForLoadState("networkidle"); + await page.context().clearCookies(); + await page.goto("/book"); + await completeBookingFlow(page, { + serviceName: SEEDED.services.beardTrim, + guest: { + firstName: "E2E", + lastName: "ApprovalTest", + email: uniqueEmail("e2e-approval"), + phone: "+44 7700 900111", + }, + }); + await expect(page.getByText("Pending Approval")).toBeVisible(); + + // Restore the seeded default so later journeys (and re-runs) see auto-confirmed bookings again. + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/owner/settings/booking-rules"); + await page.getByRole("switch", { name: "Require approval for new bookings" }).click(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText(/Saved — the customer booking flow uses these rules immediately/)).toBeVisible(); + }); + + test("9. owner configures the loyalty programme", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/owner/settings/loyalty"); + await expect(page.getByRole("heading", { name: "Loyalty Programme" })).toBeVisible(); + + const stampsInput = page.getByLabel("Stamps required for reward"); + await expect(stampsInput).toHaveValue("10"); + await stampsInput.fill("8"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved.").first()).toBeVisible(); + + await page.reload(); + await expect(page.getByLabel("Stamps required for reward")).toHaveValue("8"); + + // Restore the seeded default. + await page.getByLabel("Stamps required for reward").fill("10"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved.").first()).toBeVisible(); + }); + + test("10. owner redeems a customer's available loyalty reward on an appointment", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/owner/calendar?view=agenda"); + + // Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits + // exactly at the loyalty reward threshold with one AVAILABLE reward. + const card = page.locator(".rounded-jumbo").filter({ hasText: "Sam Osei" }).filter({ hasText: "Arrived" }); + await card.getByRole("link", { name: "View", exact: true }).click(); + + await expect(page.getByText(/Subtotal/)).toBeVisible(); + await page.getByRole("button", { name: "Redeem Reward", exact: true }).click(); + + const modal = page.getByRole("dialog"); + await expect(modal.getByText(/This can't be undone/)).toBeVisible(); + await modal.getByRole("button", { name: "Apply Reward", exact: true }).click(); + await expect(modal.getByText(/has been applied to this appointment/)).toBeVisible(); + await modal.getByRole("button", { name: "Done", exact: true }).click(); + + await expect(page.getByText("Loyalty reward")).toBeVisible(); + }); + + test("11. a barber without MANAGE_STAFF is denied access to owner administration", async ({ page }) => { + await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); + + await page.goto("/owner"); + await expect(page).toHaveURL(/\/staff$/); + await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Dashboard" })).toHaveCount(0); + }); + + test("12. the owner switches back to barber tools without losing the barber nav", async ({ page }) => { + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/owner"); + + await page.getByRole("link", { name: "Barber Tools", exact: true }).click(); + await expect(page).toHaveURL(/\/staff$/); + await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Current", exact: true })).toBeVisible(); + }); +}); diff --git a/prisma/migrations/20260803070350_owner_administration/migration.sql b/prisma/migrations/20260803070350_owner_administration/migration.sql new file mode 100644 index 0000000..dfc2112 --- /dev/null +++ b/prisma/migrations/20260803070350_owner_administration/migration.sql @@ -0,0 +1,55 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "Permission" ADD VALUE 'MANAGE_ALL_APPOINTMENTS'; +ALTER TYPE "Permission" ADD VALUE 'VIEW_REPORTING'; + +-- AlterTable +ALTER TABLE "Appointment" ADD COLUMN "discountAmount" DECIMAL(10,2); + +-- AlterTable +ALTER TABLE "LoyaltyReward" ADD COLUMN "redeemedByStaffId" TEXT; + +-- CreateTable +CREATE TABLE "StaffInvitation" ( + "id" TEXT NOT NULL, + "businessId" TEXT NOT NULL, + "staffId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "invitedByUserId" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "acceptedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StaffInvitation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "StaffInvitation_staffId_key" ON "StaffInvitation"("staffId"); + +-- CreateIndex +CREATE UNIQUE INDEX "StaffInvitation_token_key" ON "StaffInvitation"("token"); + +-- CreateIndex +CREATE INDEX "StaffInvitation_businessId_idx" ON "StaffInvitation"("businessId"); + +-- AddForeignKey +ALTER TABLE "StaffInvitation" ADD CONSTRAINT "StaffInvitation_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StaffInvitation" ADD CONSTRAINT "StaffInvitation_staffId_fkey" FOREIGN KEY ("staffId") REFERENCES "StaffProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StaffInvitation" ADD CONSTRAINT "StaffInvitation_invitedByUserId_fkey" FOREIGN KEY ("invitedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_appliedToAppointmentId_fkey" FOREIGN KEY ("appliedToAppointmentId") REFERENCES "Appointment"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_redeemedByStaffId_fkey" FOREIGN KEY ("redeemedByStaffId") REFERENCES "StaffProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260803071240_loyalty_free_service/migration.sql b/prisma/migrations/20260803071240_loyalty_free_service/migration.sql new file mode 100644 index 0000000..63f52d3 --- /dev/null +++ b/prisma/migrations/20260803071240_loyalty_free_service/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "LoyaltyProgramme" ADD COLUMN "freeServiceId" TEXT; + +-- AddForeignKey +ALTER TABLE "LoyaltyProgramme" ADD CONSTRAINT "LoyaltyProgramme_freeServiceId_fkey" FOREIGN KEY ("freeServiceId") REFERENCES "Service"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c179ccc..a4e42d0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,12 +47,20 @@ enum Permission { MANAGE_LOYALTY_PROGRAMME MANAGE_NOTIFICATION_SETTINGS VIEW_ALL_STAFF_APPOINTMENTS + /// Distinct from VIEW_ALL_STAFF_APPOINTMENTS — reassign/reschedule/cancel + /// any staff member's appointment, not just look at it. See + /// docs/phase-6-summary.md, "Permission and business-isolation rules". + MANAGE_ALL_APPOINTMENTS MANAGE_OWN_APPOINTMENTS CHECK_IN_CUSTOMERS MANAGE_LOYALTY_STAMPS MANAGE_OWN_AVAILABILITY VIEW_CUSTOMER_DIRECTORY BOOK_ON_BEHALF_OF_CUSTOMER + /// Gates the owner dashboard specifically — kept separate from + /// MANAGE_STAFF/MANAGE_BUSINESS_PROFILE so "can see aggregate business + /// health" is its own grantable capability. + VIEW_REPORTING } enum StaffStatus { @@ -164,6 +172,7 @@ model Business { roles Role[] appointments Appointment[] loyaltyProgramme LoyaltyProgramme? + staffInvitations StaffInvitation[] } /// Split from Business (rather than address fields on Business itself) so @@ -296,6 +305,8 @@ model User { cancelledAppointments Appointment[] @relation("AppointmentCancelledBy") /// Whoever initiated a booking — see Appointment.createdByUserId. createdAppointments Appointment[] @relation("AppointmentCreatedBy") + /// Owner-side: invitations this user has sent (see StaffInvitation). + sentStaffInvitations StaffInvitation[] @relation("StaffInvitationInvitedBy") } model Account { @@ -398,6 +409,10 @@ model StaffProfile { preferredByCustomers CustomerProfile[] @relation("PreferredBarber") authoredLoyaltyTransactions LoyaltyTransaction[] @relation("LoyaltyTransactionAuthor") authoredCustomerNotes CustomerNote[] @relation("CustomerNoteAuthor") + redeemedLoyaltyRewards LoyaltyReward[] @relation("LoyaltyRewardRedeemedBy") + /// Set when this profile was created via the owner "Add Staff" flow + /// rather than seeded directly — see StaffInvitationService. + invitation StaffInvitation? @@index([businessId]) } @@ -434,6 +449,37 @@ model RolePermission { @@unique([roleId, permission]) } +/// Created immediately alongside the StaffProfile/User when an owner adds a +/// staff member (see StaffInvitationService) — the profile is usable right +/// away (it already has a role, can appear on the calendar, etc.); this row +/// only tracks the "set your own password" step, so the UI can show +/// Pending vs Accepted without gating functionality on it. No real email +/// delivery this phase — `token` is surfaced as a copy-link for the owner +/// to send manually, same documented limitation as Phase 5's loyalty +/// redemption note (see docs/phase-6-summary.md). +model StaffInvitation { + id String @id @default(cuid()) + + businessId String + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) + + staffId String @unique + staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade) + + email String + token String @unique + + invitedByUserId String? + invitedByUser User? @relation("StaffInvitationInvitedBy", fields: [invitedByUserId], references: [id], onDelete: SetNull) + + expiresAt DateTime + acceptedAt DateTime? + + createdAt DateTime @default(now()) + + @@index([businessId]) +} + // ───────────────────────────────────────────────────────────────────────── // Catalog // ───────────────────────────────────────────────────────────────────────── @@ -479,9 +525,10 @@ model Service { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - staff StaffService[] - appointmentServices AppointmentService[] - loyaltyEligibleFor LoyaltyEligibleService[] + staff StaffService[] + appointmentServices AppointmentService[] + loyaltyEligibleFor LoyaltyEligibleService[] + loyaltyProgrammesUsingAsFreeReward LoyaltyProgramme[] @@index([businessId]) @@index([categoryId]) @@ -605,6 +652,14 @@ model Appointment { notes String? @db.Text + /// Snapshot of the amount deducted by a redeemed loyalty reward, if any — + /// see LoyaltyService.redeemReward. Null = no reward applied. The + /// original AppointmentService.priceAtBooking rows are never rewritten; + /// the payable total is subtotal (sum of those) minus this value, + /// computed at read time rather than also stored, so there is exactly + /// one place a future price/discount bug could hide, not two. + discountAmount Decimal? @db.Decimal(10, 2) + /// Cancellation is a status transition, never a delete — these three /// fields are only ever set together, when status becomes CANCELLED. cancellationReason String? @@ -623,9 +678,13 @@ model Appointment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - services AppointmentService[] - loyaltyTransactions LoyaltyTransaction[] - notifications Notification[] + services AppointmentService[] + loyaltyTransactions LoyaltyTransaction[] + notifications Notification[] + /// Almost always zero or one row — a reward is applied to at most one + /// appointment (see LoyaltyReward.appliedToAppointmentId) — but modelled + /// as the array Prisma requires for a one-to-many back-relation. + appliedLoyaltyRewards LoyaltyReward[] @@index([staffId, startAt]) @@index([businessId, startAt]) @@ -671,6 +730,12 @@ model LoyaltyProgramme { rewardLabel String? maxRewardValue Decimal? @db.Decimal(10, 2) + /// Which service is free when rewardType is FREE_SERVICE — null for the + /// other two reward types. See LoyaltyService.validateProgrammeConfig for + /// the "must reference an active service" rule. + freeServiceId String? + freeService Service? @relation(fields: [freeServiceId], references: [id], onDelete: SetNull) + autoApplyReward Boolean @default(true) requiresStaffApproval Boolean @default(false) @@ -774,8 +839,13 @@ model LoyaltyReward { status RewardStatus @default(AVAILABLE) appliedToAppointmentId String? - redeemedAt DateTime? - expiresAt DateTime? + appliedToAppointment Appointment? @relation(fields: [appliedToAppointmentId], references: [id], onDelete: SetNull) + + redeemedAt DateTime? + redeemedByStaffId String? + redeemedByStaff StaffProfile? @relation("LoyaltyRewardRedeemedBy", fields: [redeemedByStaffId], references: [id], onDelete: SetNull) + + expiresAt DateTime? createdAt DateTime @default(now()) diff --git a/prisma/seed.ts b/prisma/seed.ts index ae00ae9..5f9e85c 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -27,6 +27,25 @@ function minutesFromNow(minutes: number): Date { const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const prisma = new PrismaClient({ adapter }); +/** + * `role.upsert`'s `update: {}` deliberately does nothing on an existing + * role (see the two call sites below) — re-running the seed must still be + * safe to call repeatedly without duplicating anything, but a plain + * nested `permissions: { create: [...] }` only fires on the `create` + * branch, so a permission added to OWNER_PERMISSIONS/BARBER_PERMISSIONS + * after a business was first seeded would silently never reach an + * already-seeded database. This syncs RolePermission rows to exactly + * match the target list every run — found and fixed while adding Phase 6 + * permissions to a database seeded back in Phase 2. + */ +async function syncRolePermissions(roleId: string, permissions: Permission[]): Promise { + await prisma.rolePermission.deleteMany({ where: { roleId, permission: { notIn: permissions } } }); + await prisma.rolePermission.createMany({ + data: permissions.map((permission) => ({ roleId, permission })), + skipDuplicates: true, + }); +} + const OWNER_PERMISSIONS: Permission[] = [ Permission.MANAGE_BUSINESS_PROFILE, Permission.MANAGE_BOOKING_RULES, @@ -35,12 +54,14 @@ const OWNER_PERMISSIONS: Permission[] = [ Permission.MANAGE_LOYALTY_PROGRAMME, Permission.MANAGE_NOTIFICATION_SETTINGS, Permission.VIEW_ALL_STAFF_APPOINTMENTS, + Permission.MANAGE_ALL_APPOINTMENTS, Permission.MANAGE_OWN_APPOINTMENTS, Permission.CHECK_IN_CUSTOMERS, Permission.MANAGE_LOYALTY_STAMPS, Permission.MANAGE_OWN_AVAILABILITY, Permission.VIEW_CUSTOMER_DIRECTORY, Permission.BOOK_ON_BEHALF_OF_CUSTOMER, + Permission.VIEW_REPORTING, ]; const BARBER_PERMISSIONS: Permission[] = [ @@ -123,6 +144,7 @@ async function main() { permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) }, }, }); + await syncRolePermissions(ownerRole.id, OWNER_PERMISSIONS); const barberRole = await prisma.role.upsert({ where: { businessId_name: { businessId: business.id, name: "Barber" } }, @@ -134,6 +156,7 @@ async function main() { permissions: { create: BARBER_PERMISSIONS.map((permission) => ({ permission })) }, }, }); + await syncRolePermissions(barberRole.id, BARBER_PERMISSIONS); const category = await prisma.serviceCategory.upsert({ where: { id: `${business.id}-senior-barber` }, @@ -716,6 +739,219 @@ async function main() { }); } + // ── Phase 6: owner administration fixtures ───────────────────────────── + + // A service with no eligible staff — exercises the owner dashboard's + // "service has no eligible barber" configuration warning. + await prisma.service.upsert({ + where: { id: `${business.id}-kids-haircut` }, + update: {}, + create: { + id: `${business.id}-kids-haircut`, + businessId: business.id, + categoryId: category.id, + name: "Kids Haircut (Under 12)", + durationMinutes: 20, + price: 12, + }, + }); + + // An inactive staff member — exercises the "Inactive" filter and + // reactivation flow on the owner Team page. + const inactivePasswordHash = await bcrypt.hash("password123", 12); + const inactiveStaffUser = await prisma.user.upsert({ + where: { email: "priya.shah@blakeheritage.co.uk" }, + update: {}, + create: { + email: "priya.shah@blakeheritage.co.uk", + name: "Priya Shah", + passwordHash: inactivePasswordHash, + emailVerified: new Date(), + staffProfile: { + create: { + businessId: business.id, + roleId: barberRole.id, + status: "INACTIVE", + bio: "Former Saturday specialist, currently on extended leave.", + services: { create: [{ serviceId: beardTrim.id }] }, + }, + }, + }, + include: { staffProfile: true }, + }); + + // A pending staff invitation — a new hire the owner has added but who + // hasn't accepted yet, exercising the "Pending" badge and "Get Invite + // Link" flow. No passwordHash/emailVerified — matches a real + // not-yet-accepted invitee. + const pendingStaffUser = await prisma.user.upsert({ + where: { email: "owen.clarke@blakeheritage.co.uk" }, + update: {}, + create: { + email: "owen.clarke@blakeheritage.co.uk", + name: "Owen Clarke", + staffProfile: { + create: { + businessId: business.id, + roleId: barberRole.id, + bio: "Joining the team next month.", + services: { create: [{ serviceId: haircut.id }] }, + }, + }, + }, + include: { staffProfile: true }, + }); + if (pendingStaffUser.staffProfile) { + await prisma.staffInvitation.upsert({ + where: { staffId: pendingStaffUser.staffProfile.id }, + update: {}, + create: { + businessId: business.id, + staffId: pendingStaffUser.staffProfile.id, + email: pendingStaffUser.email!, + token: "dev-seed-invite-owen-clarke", + invitedByUserId: ownerUser.id, + expiresAt: daysFromNow(7, 12), + }, + }); + } + + // A genuinely future (not-today), non-cancelled appointment for Leo that + // isn't part of any other status-variety fixture above — exercises the + // owner's "affected appointments" conflict review when tightening opening + // hours or deactivating a staff member with upcoming bookings. Uses Priya + // Kapoor (not Alex Chen) to avoid the Phase 4/5 e2e "book again" fixture. + if (barberUser.staffProfile) { + const leoFutureStart = daysFromNow(4, 12, 0); + await prisma.appointment.upsert({ + where: { id: `${business.id}-appt-leo-future` }, + update: {}, + create: { + id: `${business.id}-appt-leo-future`, + businessId: business.id, + customerId: secondGuestCustomer.id, + staffId: barberUser.staffProfile.id, + status: "CONFIRMED", + startAt: leoFutureStart, + endAt: new Date(leoFutureStart.getTime() + haircut.durationMinutes * 60_000), + services: { + create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], + }, + }, + }); + } + + // A completed past visit with an already-redeemed reward — exercises + // redemption history (as opposed to Sam's still-AVAILABLE reward above), + // and the appointment's discountAmount/final-total display. + if (barberUser.staffProfile) { + const leo = barberUser.staffProfile; + const jordanRedeemedApptStart = daysFromNow(-30, 11, 0); + const jordanRedeemedAppt = await prisma.appointment.upsert({ + where: { id: `${business.id}-appt-jordan-redeemed` }, + update: {}, + create: { + id: `${business.id}-appt-jordan-redeemed`, + businessId: business.id, + customerId: guestCustomer.id, + staffId: leo.id, + status: "COMPLETED", + startAt: jordanRedeemedApptStart, + endAt: new Date(jordanRedeemedApptStart.getTime() + haircut.durationMinutes * 60_000), + discountAmount: 11.0, + services: { + create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], + }, + }, + }); + + const loyaltyProgramme = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.id } }); + const jordanAccount = await prisma.loyaltyAccount.upsert({ + where: { customerId_loyaltyProgrammeId: { customerId: guestCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id } }, + update: {}, + create: { customerId: guestCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id, currentStamps: 2 }, + }); + const jordanTxCount = await prisma.loyaltyTransaction.count({ where: { loyaltyAccountId: jordanAccount.id } }); + if (jordanTxCount === 0) { + await prisma.loyaltyTransaction.createMany({ + data: [ + ...Array.from({ length: loyaltyProgramme.stampsRequired }, () => ({ + loyaltyAccountId: jordanAccount.id, + type: "STAMP_ADDED" as const, + amount: 1, + authoredByStaffId: leo.id, + })), + { + loyaltyAccountId: jordanAccount.id, + type: "REWARD_REDEEMED" as const, + amount: -loyaltyProgramme.stampsRequired, + authoredByStaffId: leo.id, + appointmentId: jordanRedeemedAppt.id, + }, + // Two stamps earned since redeeming, keeping currentStamps in sync. + { loyaltyAccountId: jordanAccount.id, type: "STAMP_ADDED" as const, amount: 1, authoredByStaffId: leo.id }, + { loyaltyAccountId: jordanAccount.id, type: "STAMP_ADDED" as const, amount: 1, authoredByStaffId: leo.id }, + ], + }); + await prisma.loyaltyReward.create({ + data: { + loyaltyAccountId: jordanAccount.id, + rewardType: loyaltyProgramme.rewardType, + value: loyaltyProgramme.rewardValue, + label: loyaltyProgramme.rewardLabel ?? "Reward", + status: "REDEEMED", + appliedToAppointmentId: jordanRedeemedAppt.id, + redeemedAt: jordanRedeemedApptStart, + redeemedByStaffId: leo.id, + }, + }); + } + + // ── Audit log: representative owner-action history ──────────────────── + const auditLogCount = await prisma.auditLog.count({ where: { entityType: "Business", entityId: business.id } }); + if (auditLogCount === 0) { + await prisma.auditLog.createMany({ + data: [ + { + actorUserId: ownerUser.id, + action: "BUSINESS_PROFILE_UPDATED", + entityType: "Business", + entityId: business.id, + metadata: { fields: ["description", "phone"] }, + }, + { + actorUserId: ownerUser.id, + action: "STAFF_ADDED", + entityType: "StaffProfile", + entityId: pendingStaffUser.staffProfile?.id ?? business.id, + metadata: { name: "Owen Clarke", role: "Barber" }, + }, + { + actorUserId: ownerUser.id, + action: "STAFF_DEACTIVATED", + entityType: "StaffProfile", + entityId: inactiveStaffUser.staffProfile?.id ?? business.id, + metadata: { name: "Priya Shah" }, + }, + { + actorUserId: ownerUser.id, + action: "LOYALTY_REWARD_REDEEMED", + entityType: "LoyaltyReward", + entityId: jordanAccount.id, + metadata: { customer: "Jordan Blake", appointmentId: jordanRedeemedAppt.id }, + }, + { + actorUserId: ownerUser.id, + action: "LOYALTY_CONFIG_UPDATED", + entityType: "LoyaltyProgramme", + entityId: loyaltyProgramme.id, + metadata: { isEnabled: true, rewardType: loyaltyProgramme.rewardType }, + }, + ], + }); + } + } + console.log("Seed complete:", { business: business.name, roles: [ownerRole.name, barberRole.name], diff --git a/src/app/(auth)/invite/accept/page.tsx b/src/app/(auth)/invite/accept/page.tsx new file mode 100644 index 0000000..b7d4784 --- /dev/null +++ b/src/app/(auth)/invite/accept/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { prisma } from "@/lib/db/prisma"; +import { validateInvitationToken } from "@/services/staff/staff-invitation.service"; +import { AcceptInvitationForm } from "@/features/auth/components/accept-invitation-form"; + +export const metadata: Metadata = { title: "Accept Invitation" }; +export const dynamic = "force-dynamic"; + +export default async function AcceptInvitationPage({ + searchParams, +}: { + searchParams: Promise<{ token?: string }>; +}) { + const { token } = await searchParams; + + if (!token) { + return ; + } + + const check = await validateInvitationToken(prisma, token); + if (!check.valid) { + const message = + check.reason === "EXPIRED" + ? "This invitation link has expired. Ask the business owner to send you a new one." + : check.reason === "ALREADY_ACCEPTED" + ? "This invitation has already been used." + : "This invitation link isn't valid."; + return ; + } + + return ( +

+

Welcome, {check.invitation.staff.user.name}

+

Finish setting up your staff account.

+ +
+ ); +} + +function InvalidInvitation({ message }: { message: string }) { + return ( +
+

Invitation Not Available

+

{message}

+ + Go to Sign In + +
+ ); +} diff --git a/src/app/(owner)/layout.tsx b/src/app/(owner)/layout.tsx index 553b89d..abd5d4c 100644 --- a/src/app/(owner)/layout.tsx +++ b/src/app/(owner)/layout.tsx @@ -3,6 +3,7 @@ import { auth } from "@/auth"; import { StaffShell } from "@/components/navigation/staff-shell"; import { hasPermission } from "@/lib/auth/permissions"; import { Permission } from "@/generated/prisma/enums"; +import { OWNER_NAV_ITEMS } from "@/lib/navigation/staff-nav-items"; /** * Owner is a permission on a staff account, not a separate account type @@ -20,5 +21,9 @@ export default async function OwnerLayout({ children }: { children: React.ReactN redirect("/staff"); } - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/(owner)/owner/appointments/[id]/page.tsx b/src/app/(owner)/owner/appointments/[id]/page.tsx new file mode 100644 index 0000000..9e9957d --- /dev/null +++ b/src/app/(owner)/owner/appointments/[id]/page.tsx @@ -0,0 +1,268 @@ +import type { Metadata } from "next"; +import { CalendarPlus, Mail, Phone } from "lucide-react"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { auth } from "@/auth"; +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 { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { getLoyaltySummary } from "@/services/loyalty/loyalty.service"; +import { getAppointmentById } from "@/services/scheduling/appointment.service"; +import { assertAppointmentInBusiness, 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 { ownerAppointmentActions } from "@/features/owner/actions/appointment-action-set"; +import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button"; +import type { AppointmentStatus } from "@/types/domain"; + +export const metadata: Metadata = { title: "Appointment Details" }; +export const dynamic = "force-dynamic"; + +const STATUS_MAP: Record = { + PENDING: "pending_approval", + CONFIRMED: "confirmed", + CHECKED_IN: "arrived", + IN_PROGRESS: "in_progress", + COMPLETED: "completed", + CANCELLED: "cancelled", + NO_SHOW: "no_show", +}; +const RESCHEDULABLE = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]); + +export default async function OwnerAppointmentDetailsPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS); + if (!canView) redirect("/staff"); + + const owner = await prisma.staffProfile.findUnique({ + where: { userId: session.user.id }, + include: { business: { select: { id: true, timezone: true } } }, + }); + if (!owner) redirect("/staff"); + + const { id } = await params; + const appointment = await getAppointmentById(prisma, id); + if (!appointment) notFound(); + + try { + assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId }); + } 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: owner.businessId }), + appointment.cancelledByUserId + ? prisma.user.findUnique({ where: { id: appointment.cancelledByUserId }, select: { name: true } }) + : null, + ]); + + const tz = owner.business.timezone; + const totalDuration = appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0); + const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0); + const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0; + const total = subtotal - discount; + + const canRedeemReward = + loyalty?.availableReward && + appointment.discountAmount === null && + appointment.status !== "CANCELLED"; + + return ( +
+ + +
+ +
+ + + Ref: {appointment.id.slice(-8)} + +
+ +
+ + {appointment.staff.user.name ?? "Staff"} + + + {appointment.customer.firstName} {appointment.customer.lastName} + +

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

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

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

+
+
+ Date +

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

+
+
+ Time +

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

+
+
+ Duration +

{totalDuration} min

+
+
+ +
+
+ Subtotal + £{subtotal.toFixed(2)} +
+ {discount > 0 && ( +
+ Loyalty reward + -£{discount.toFixed(2)} +
+ )} +
+ Total + £{total.toFixed(2)} +
+
+ + {appointment.notes && ( +
+ Booking Notes +

{appointment.notes}

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

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

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

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

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

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

+ {appointment.cancellationReason && ( +

“{appointment.cancellationReason}”

+ )} +
+ )} +
+ +
+ + + {RESCHEDULABLE.has(appointment.status) && ( + + Reschedule / Reassign + + )} + {appointment.status === "COMPLETED" && } +
+ + + +
+ {loyalty?.isEnabled && } + {canRedeemReward && loyalty.availableReward && ( + + )} +
+
+
+ ); +} diff --git a/src/app/(owner)/owner/calendar/page.tsx b/src/app/(owner)/owner/calendar/page.tsx new file mode 100644 index 0000000..f4d1991 --- /dev/null +++ b/src/app/(owner)/owner/calendar/page.tsx @@ -0,0 +1,310 @@ +import type { Metadata } from "next"; +import { CalendarX2, Plus } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +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 { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +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 } 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 { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button"; +import { cn } from "@/lib/utils/cn"; +import type { AppointmentStatus } from "@/types/domain"; + +export const metadata: Metadata = { title: "All-Staff Calendar" }; +export const dynamic = "force-dynamic"; + +const STATUS_MAP: Record = { + PENDING: "pending_approval", + CONFIRMED: "confirmed", + CHECKED_IN: "arrived", + IN_PROGRESS: "in_progress", + COMPLETED: "completed", + CANCELLED: "cancelled", + NO_SHOW: "no_show", +}; + +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)}`; +} + +export default async function OwnerCalendarPage({ + searchParams, +}: { + searchParams: Promise<{ date?: string; view?: string; staffId?: string }>; +}) { + const session = await auth(); + if (!session?.user) redirect("/sign-in?callbackUrl=/owner/calendar"); + + const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS); + if (!canView) redirect("/staff"); + + const owner = await prisma.staffProfile.findUnique({ + where: { userId: session.user.id }, + include: { business: { select: { id: true, name: true, timezone: true } } }, + }); + if (!owner) redirect("/staff"); + + const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams; + const today = todayIn(owner.business.timezone); + const date: CalendarDate = dateParam ?? today; + const view = viewParam === "agenda" ? "agenda" : "day"; + const weekday = weekdayOf(date); + const dateUtcMidnight = calendarDateToUtcMidnight(date); + const businessTimezone = owner.business.timezone; + + const allStaff = await prisma.staffProfile.findMany({ + where: { businessId: owner.businessId, status: "ACTIVE" }, + include: { user: { select: { id: true, name: true } } }, + orderBy: { createdAt: "asc" }, + }); + const selectedStaff = staffIdParam ? allStaff.find((s) => s.id === staffIdParam) : undefined; + + const [appointments, workingHoursAll, breakDaysAll, timeOffAll] = await Promise.all([ + listAppointments(prisma, { + businessId: owner.businessId, + staffId: selectedStaff?.id, + fromDate: date, + toDate: date, + businessTimezone, + }), + prisma.workingHours.findMany({ + where: { staff: { businessId: owner.businessId }, dayOfWeek: weekday }, + }), + prisma.breakDay.findMany({ + where: { dayOfWeek: weekday, break: { staff: { businessId: owner.businessId } } }, + include: { break: true }, + }), + prisma.timeOff.findMany({ + where: { + staff: { businessId: owner.businessId }, + status: "APPROVED", + startDate: { lte: dateUtcMidnight }, + endDate: { gte: dateUtcMidnight }, + }, + }), + ]); + + const rewardFlags = await getAvailableRewardFlags(prisma, { + businessId: owner.businessId, + customerIds: [...new Set(appointments.map((a) => a.customerId))], + }); + + const currentTimeMinutes = date === today ? minutesFromMidnight(new Date(), businessTimezone) : null; + + const staffToRender = selectedStaff ? [selectedStaff] : allStaff; + + function buildColumnFor(staffId: string) { + const workingHours = workingHoursAll.find((h) => h.staffId === staffId); + const workingWindow = + workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null + ? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes } + : null; + + const blocks: CalendarBlock[] = [ + ...breakDaysAll + .filter((bd) => bd.break.staffId === staffId) + .map((bd) => ({ + id: `break-${bd.id}`, + startMinutes: bd.break.startMinutes, + endMinutes: bd.break.endMinutes, + label: bd.break.label, + kind: "break" as const, + })), + ...timeOffAll + .filter((t) => t.staffId === staffId && 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 wholeDayOff = timeOffAll.some((t) => t.staffId === staffId && t.startMinutes === null); + + const staffAppointments: CalendarAppointment[] = appointments + .filter((a) => a.staffId === staffId) + .map((a) => ({ + id: a.id, + startMinutes: minutesFromMidnight(a.startAt, businessTimezone), + endMinutes: minutesFromMidnight(a.endAt, businessTimezone), + 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, + })); + + return { workingWindow, blocks, wholeDayOff, staffAppointments }; + } + + return ( +
+
+

Calendar

+
+ {selectedStaff && } + +
+
+ + + +
+ + All Staff + + {allStaff.map((s) => ( + + {s.user.name} + + ))} +
+ +
+ + Day + + + Agenda + +
+ +
+ {view === "day" ? ( + staffToRender.length === 0 ? ( +
+ +
+ ) : ( +
+ {staffToRender.map((s) => { + const column = buildColumnFor(s.id); + return ( +
+

+ {s.user.name} +

+ {column.wholeDayOff ? ( +
+ +
+ ) : ( + + )} +
+ ); + })} +
+ ) + ) : appointments.length === 0 ? ( +
+ +
+ ) : ( +
+ {[...appointments] + .sort((a, b) => a.startAt.getTime() - b.startAt.getTime()) + .map((a) => ( +
+ {!selectedStaff && ( +

+ {a.staff.user.name} +

+ )} + s.service.name).join(" + ")} + person={{ id: a.customerId, name: `${a.customer.firstName} ${a.customer.lastName}` }} + personLabel="for" + formattedTime={formatRange( + minutesFromMidnight(a.startAt, businessTimezone), + minutesFromMidnight(a.endAt, businessTimezone), + )} + hasNotes={!!a.notes} + hasAvailableReward={rewardFlags.get(a.customerId) ?? false} + actions={ + + View + + } + /> +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/(owner)/owner/page.tsx b/src/app/(owner)/owner/page.tsx index 61aab8b..b465ff5 100644 --- a/src/app/(owner)/owner/page.tsx +++ b/src/app/(owner)/owner/page.tsx @@ -1,9 +1,189 @@ import type { Metadata } from "next"; -import { ChartLine } from "lucide-react"; -import { ComingSoon } from "@/components/feedback/coming-soon"; +import Link from "next/link"; +import { + AlertTriangle, + Calendar, + CalendarX2, + CheckCheck, + Gift, + Stamp, + TrendingUp, + UserX, + Users, +} from "lucide-react"; +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { Card } from "@/components/ui/card"; +import { prisma } from "@/lib/db/prisma"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { todayIn } from "@/lib/time/business-time"; +import { getDashboardSummary } from "@/services/reporting/owner-reporting.service"; export const metadata: Metadata = { title: "Business Dashboard" }; +export const dynamic = "force-dynamic"; -export default function OwnerDashboardPage() { - return ; +function StatTile({ icon: Icon, label, value, hint }: { icon: typeof Calendar; label: string; value: string | number; hint?: string }) { + return ( + +
+
+

{value}

+ {hint &&

{hint}

} +
+ ); +} + +export default async function OwnerDashboardPage() { + const session = await auth(); + if (!session?.user) redirect("/sign-in?callbackUrl=/owner"); + + const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING); + if (!canView) redirect("/staff"); + + const staff = await prisma.staffProfile.findUnique({ + where: { userId: session.user.id }, + include: { business: { select: { id: true, name: true, timezone: true } } }, + }); + if (!staff) redirect("/staff"); + + const today = todayIn(staff.business.timezone); + const summary = await getDashboardSummary(prisma, { + businessId: staff.businessId, + businessTimezone: staff.business.timezone, + date: today, + }); + + return ( +
+
+

Dashboard

+

{staff.business.name} — today

+
+ + {summary.warnings.length > 0 && ( +
+ {summary.warnings.map((w, i) => ( +
+
+ ))} +
+ )} + +
+ + + + +
+ +
+ + + + +
+ +
+ +

+

+ {summary.staffWorkingToday.length === 0 ? ( +

No one is scheduled to work today.

+ ) : ( +
    + {summary.staffWorkingToday.map((s) => ( +
  • + {s.name} + Working +
  • + ))} +
+ )} + {summary.staffOnTimeOffToday.length > 0 && ( +
    + {summary.staffOnTimeOffToday.map((s) => ( +
  • + {s.name} + + {s.reason ?? "Time off"} + +
  • + ))} +
+ )} +
+ + +

+

+

Completed visits, last 30 days.

+ {summary.popularServices.length === 0 ? ( +

No completed visits in the last 30 days yet.

+ ) : ( +
    + {summary.popularServices.map((s) => ( +
  • + {s.name} + {s.count} +
  • + ))} +
+ )} +
+
+ + +

+

+ {summary.recentLoyaltyActivity.length === 0 ? ( +

No loyalty activity yet.

+ ) : ( +
    + {summary.recentLoyaltyActivity.map((t) => ( +
  • + + {t.loyaltyAccount.customer.firstName} {t.loyaltyAccount.customer.lastName} + {" "} + — {t.amount > 0 ? "+" : ""} + {t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} ({t.type.replace(/_/g, " ").toLowerCase()}) + {t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""} +
  • + ))} +
+ )} +
+ +
+ + Manage Team + + + Update Booking Rules + +
+
+ ); } diff --git a/src/app/(owner)/owner/services/page.tsx b/src/app/(owner)/owner/services/page.tsx new file mode 100644 index 0000000..12e1be5 --- /dev/null +++ b/src/app/(owner)/owner/services/page.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db/prisma"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { listCategories } from "@/services/catalogue/service-catalogue.service"; +import { CatalogueManager } from "@/features/owner/components/catalogue-manager"; + +export const metadata: Metadata = { title: "Services" }; +export const dynamic = "force-dynamic"; + +export default async function OwnerServicesPage() { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canManage = await hasPermission(session.user.id, Permission.MANAGE_SERVICES); + if (!canManage) redirect("/staff"); + + const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } }); + if (!owner) redirect("/staff"); + + const categories = await listCategories(prisma, owner.businessId); + + return ( +
+

Services

+ ({ + id: c.id, + name: c.name, + isActive: c.isActive, + services: c.services.map((s) => ({ + id: s.id, + categoryId: s.categoryId, + name: s.name, + description: s.description ?? "", + durationMinutes: s.durationMinutes, + price: s.price.toString(), + bufferBeforeMinutes: s.bufferBeforeMinutes, + bufferAfterMinutes: s.bufferAfterMinutes, + isActive: s.isActive, + allowOnlineBooking: s.allowOnlineBooking, + })), + }))} + /> +
+ ); +} diff --git a/src/app/(owner)/owner/settings/booking-rules/page.tsx b/src/app/(owner)/owner/settings/booking-rules/page.tsx new file mode 100644 index 0000000..36df9f1 --- /dev/null +++ b/src/app/(owner)/owner/settings/booking-rules/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { getBookingRule } from "@/services/scheduling/booking-rule.service"; +import { BookingRulesForm } from "@/features/owner/components/booking-rules-form"; + +export const metadata: Metadata = { title: "Booking Rules" }; +export const dynamic = "force-dynamic"; + +export default async function OwnerBookingRulesPage() { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES); + if (!canManage) redirect("/staff"); + + const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } }); + if (!owner) redirect("/staff"); + + const rule = await getBookingRule(prisma, owner.businessId); + if (!rule) redirect("/owner"); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/app/(owner)/owner/settings/business/page.tsx b/src/app/(owner)/owner/settings/business/page.tsx new file mode 100644 index 0000000..5a94fe7 --- /dev/null +++ b/src/app/(owner)/owner/settings/business/page.tsx @@ -0,0 +1,61 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { getBusinessProfile } from "@/services/business/business.service"; +import { BusinessDetailsForm } from "@/features/owner/components/business-details-form"; + +export const metadata: Metadata = { title: "Business Details" }; +export const dynamic = "force-dynamic"; + +export default async function OwnerBusinessDetailsPage() { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE); + if (!canManage) redirect("/staff"); + + const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } }); + if (!staff) redirect("/staff"); + + const business = await getBusinessProfile(prisma, staff.businessId); + if (!business) redirect("/owner"); + + const location = business.locations[0]; + + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/app/(owner)/owner/settings/hours/page.tsx b/src/app/(owner)/owner/settings/hours/page.tsx new file mode 100644 index 0000000..9e42602 --- /dev/null +++ b/src/app/(owner)/owner/settings/hours/page.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { getOpeningHours } from "@/services/business/business.service"; +import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form"; + +export const metadata: Metadata = { title: "Opening Hours" }; +export const dynamic = "force-dynamic"; + +export default async function OwnerOpeningHoursPage() { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE); + if (!canManage) redirect("/staff"); + + const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } }); + if (!staff) redirect("/staff"); + + const hours = await getOpeningHours(prisma, staff.businessId); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/app/(owner)/owner/settings/loyalty/page.tsx b/src/app/(owner)/owner/settings/loyalty/page.tsx new file mode 100644 index 0000000..f4df6f5 --- /dev/null +++ b/src/app/(owner)/owner/settings/loyalty/page.tsx @@ -0,0 +1,77 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { getProgrammeConfig } from "@/services/loyalty/loyalty.service"; +import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form"; + +export const metadata: Metadata = { title: "Loyalty Programme" }; +export const dynamic = "force-dynamic"; + +export default async function OwnerLoyaltyConfigPage() { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME); + if (!canManage) redirect("/staff"); + + const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } }); + if (!owner) redirect("/staff"); + + const [programme, services, staff] = await Promise.all([ + getProgrammeConfig(prisma, owner.businessId), + prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }), + prisma.staffProfile.findMany({ + where: { businessId: owner.businessId, status: "ACTIVE" }, + include: { user: { select: { name: true } } }, + }), + ]); + + const initialConfig = programme + ? { + isEnabled: programme.isEnabled, + stampsRequired: programme.stampsRequired, + rewardType: programme.rewardType, + rewardValue: programme.rewardValue?.toString(), + rewardLabel: programme.rewardLabel ?? undefined, + maxRewardValue: programme.maxRewardValue?.toString(), + freeServiceId: programme.freeServiceId ?? undefined, + autoApplyReward: programme.autoApplyReward, + requiresStaffApproval: programme.requiresStaffApproval, + stampsExpireAfterDays: programme.stampsExpireAfterDays ?? undefined, + rewardExpiresAfterDays: programme.rewardExpiresAfterDays ?? undefined, + earnOnCancelled: programme.earnOnCancelled, + earnOnNoShow: programme.earnOnNoShow, + cardBackgroundColor: programme.cardBackgroundColor ?? undefined, + cardBackgroundImageUrl: programme.cardBackgroundImageUrl ?? undefined, + stampIcon: programme.stampIcon ?? undefined, + termsLine: programme.termsLine ?? undefined, + } + : { + isEnabled: false, + stampsRequired: 10, + rewardType: "PERCENTAGE" as const, + autoApplyReward: true, + requiresStaffApproval: false, + earnOnCancelled: false, + earnOnNoShow: false, + }; + + return ( +
+ +
+ s.serviceId) ?? []} + initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []} + services={services.map((s) => ({ id: s.id, name: s.name }))} + staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))} + /> +
+
+ ); +} diff --git a/src/app/(owner)/owner/settings/page.tsx b/src/app/(owner)/owner/settings/page.tsx new file mode 100644 index 0000000..977030e --- /dev/null +++ b/src/app/(owner)/owner/settings/page.tsx @@ -0,0 +1,56 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { Bell, Building2, CalendarClock, ClipboardList, Crown, ScissorsSquare } from "lucide-react"; +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +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"; + +export const metadata: Metadata = { title: "Owner Settings" }; +export const dynamic = "force-dynamic"; + +const SETTINGS_LINKS = [ + { href: "/owner/settings/business", label: "Business Details", icon: Building2 }, + { href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock }, + { href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList }, + { href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown }, + { href: "/staff/settings/notifications", label: "Notifications", icon: Bell }, +] as const; + +export default async function OwnerSettingsPage() { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF); + if (!canManage) redirect("/staff"); + + return ( +
+

Owner Settings

+ + + +
+ ); +} diff --git a/src/app/(owner)/owner/team/[id]/hours/page.tsx b/src/app/(owner)/owner/team/[id]/hours/page.tsx new file mode 100644 index 0000000..fefd2e3 --- /dev/null +++ b/src/app/(owner)/owner/team/[id]/hours/page.tsx @@ -0,0 +1,76 @@ +import type { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission, Weekday } from "@/generated/prisma/enums"; +import { getWorkingHours } from "@/services/staff/staff.service"; +import { WorkingHoursForm } from "@/features/staff/components/working-hours-form"; +import { updateStaffWorkingHoursAction } from "@/features/owner/actions/staff"; + +export const metadata: Metadata = { title: "Staff 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 OwnerStaffWorkingHoursPage({ params }: { params: Promise<{ id: string }> }) { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF); + if (!canManage) redirect("/staff"); + + const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } }); + if (!owner) redirect("/staff"); + + const { id } = await params; + const target = await prisma.staffProfile.findFirst({ + where: { id, businessId: owner.businessId }, + include: { user: { select: { name: true } } }, + }); + if (!target) notFound(); + + const [workingHours, businessHours] = await Promise.all([ + getWorkingHours(prisma, id), + prisma.businessHours.findMany({ where: { businessId: owner.businessId } }), + ]); + + const businessHoursLabel = Object.fromEntries( + DAY_ORDER.map((day) => { + const hours = businessHours.find((h) => h.dayOfWeek === day); + const label = + !hours || hours.isClosed || hours.opensAtMinutes === null || hours.closesAtMinutes === null + ? "Closed" + : `${formatMinutes(hours.opensAtMinutes)}–${formatMinutes(hours.closesAtMinutes)}`; + return [day, label]; + }), + ) as Record; + + return ( +
+ +
+ updateStaffWorkingHoursAction(id, days)} + /> +
+
+ ); +} diff --git a/src/app/(owner)/owner/team/[id]/page.tsx b/src/app/(owner)/owner/team/[id]/page.tsx new file mode 100644 index 0000000..777f8ba --- /dev/null +++ b/src/app/(owner)/owner/team/[id]/page.tsx @@ -0,0 +1,82 @@ +import type { Metadata } from "next"; +import { Mail, Phone } from "lucide-react"; +import { notFound, redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { TopNav } from "@/components/navigation/top-nav"; +import { Card } from "@/components/ui/card"; +import { prisma } from "@/lib/db/prisma"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { getStaffDetailById, listRolesForBusiness } from "@/services/staff/staff.service"; +import { StaffDetailPanel } from "@/features/owner/components/staff-detail-panel"; + +export const metadata: Metadata = { title: "Staff Details" }; +export const dynamic = "force-dynamic"; + +export default async function OwnerStaffDetailPage({ params }: { params: Promise<{ id: string }> }) { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF); + if (!canManage) redirect("/staff"); + + const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } }); + if (!owner) redirect("/staff"); + + const { id } = await params; + const [target, roles, allServices] = await Promise.all([ + getStaffDetailById(prisma, { staffId: id, businessId: owner.businessId }), + listRolesForBusiness(prisma, owner.businessId), + prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }), + ]); + if (!target) notFound(); + + return ( +
+ +
+
+ + {target.user.image ? ( + // eslint-disable-next-line @next/next/no-img-element -- external avatar URL, no next/image domain config for it + + ) : ( +
+ {(target.user.name ?? "?").charAt(0)} +
+ )} +

{target.user.name}

+

+ {target.role.name} · {target.status === "ACTIVE" ? "Active" : target.status === "ON_BREAK" ? "On Break" : "Inactive"} +

+

Joined {target.createdAt.toLocaleDateString("en-GB", { month: "short", year: "numeric" })}

+ {target.bio &&

{target.bio}

} +
+ +
+ {target.user.phone && ( + + + )} + {target.user.email && ( + + + )} +
+
+ + ({ id: r.id, name: r.name }))} + allServices={allServices.map((s) => ({ id: s.id, name: s.name }))} + initialServiceIds={target.services.map((s) => s.serviceId)} + status={target.status} + hasPendingInvitation={!!target.invitation && !target.invitation.acceptedAt} + /> +
+
+ ); +} diff --git a/src/app/(owner)/owner/team/add/page.tsx b/src/app/(owner)/owner/team/add/page.tsx new file mode 100644 index 0000000..1fc7db7 --- /dev/null +++ b/src/app/(owner)/owner/team/add/page.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { listRolesForBusiness } from "@/services/staff/staff.service"; +import { AddStaffForm } from "@/features/owner/components/add-staff-form"; + +export const metadata: Metadata = { title: "Add Staff" }; +export const dynamic = "force-dynamic"; + +export default async function OwnerAddStaffPage() { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF); + if (!canManage) redirect("/staff"); + + const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } }); + if (!owner) redirect("/staff"); + + const [roles, services] = await Promise.all([ + listRolesForBusiness(prisma, owner.businessId), + prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }), + ]); + + return ( +
+ +
+ ({ id: r.id, name: r.name }))} services={services.map((s) => ({ id: s.id, name: s.name }))} /> +
+
+ ); +} diff --git a/src/app/(owner)/owner/team/page.tsx b/src/app/(owner)/owner/team/page.tsx index ea81c63..ff9b809 100644 --- a/src/app/(owner)/owner/team/page.tsx +++ b/src/app/(owner)/owner/team/page.tsx @@ -1,9 +1,95 @@ import type { Metadata } from "next"; -import { Users } from "lucide-react"; -import { ComingSoon } from "@/components/feedback/coming-soon"; +import Link from "next/link"; +import { Plus, Search } from "lucide-react"; +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db/prisma"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { listStaffForBusiness } from "@/services/staff/staff.service"; +import { cn } from "@/lib/utils/cn"; -export const metadata: Metadata = { title: "Team Management" }; +export const metadata: Metadata = { title: "Team" }; +export const dynamic = "force-dynamic"; -export default function OwnerTeamPage() { - return ; +export default async function OwnerTeamPage({ + searchParams, +}: { + searchParams: Promise<{ q?: string }>; +}) { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF); + if (!canManage) redirect("/staff"); + + const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } }); + if (!owner) redirect("/staff"); + + const { q = "" } = await searchParams; + const staff = await listStaffForBusiness(prisma, { businessId: owner.businessId, q, includeInactive: true }); + + return ( +
+
+

Team

+ +
+ +
+
+ ); } diff --git a/src/app/(staff)/staff/appointments/[id]/page.tsx b/src/app/(staff)/staff/appointments/[id]/page.tsx index 9d3fa3b..c9bfc0e 100644 --- a/src/app/(staff)/staff/appointments/[id]/page.tsx +++ b/src/app/(staff)/staff/appointments/[id]/page.tsx @@ -13,6 +13,7 @@ import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/sta 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 { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button"; import type { AppointmentStatus } from "@/types/domain"; export const metadata: Metadata = { title: "Appointment Details" }; @@ -74,6 +75,9 @@ export default async function StaffAppointmentDetailsPage({ 0, ); const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0); + const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0; + const canRedeemReward = + loyalty?.availableReward && appointment.discountAmount === null && appointment.status !== "CANCELLED"; return (
@@ -163,6 +167,9 @@ export default async function StaffAppointmentDetailsPage({

{totalDuration} min · £{totalPrice.toFixed(2)} + {discount > 0 && ( + (-£{discount.toFixed(2)} reward) + )}

@@ -257,9 +264,19 @@ export default async function StaffAppointmentDetailsPage({ - {loyalty?.isEnabled && ( - - )} +
+ {loyalty?.isEnabled && ( + + )} + {canRedeemReward && loyalty?.availableReward && ( + + )} +
); diff --git a/src/app/owner/appointments/[id]/reschedule/layout.tsx b/src/app/owner/appointments/[id]/reschedule/layout.tsx new file mode 100644 index 0000000..4e760fc --- /dev/null +++ b/src/app/owner/appointments/[id]/reschedule/layout.tsx @@ -0,0 +1,10 @@ +/** + * Deliberately outside the (owner) route group, same reasoning as + * src/app/staff/appointments/[id]/reschedule/layout.tsx: this page reuses + * BarberDateTimePicker, whose own fixed bottom action bar would otherwise + * sit under the persistent owner sidebar/bottom-nav and swallow clicks + * meant for "Confirm Reschedule". + */ +export default function OwnerRescheduleLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/src/app/owner/appointments/[id]/reschedule/page.tsx b/src/app/owner/appointments/[id]/reschedule/page.tsx new file mode 100644 index 0000000..34ccc2b --- /dev/null +++ b/src/app/owner/appointments/[id]/reschedule/page.tsx @@ -0,0 +1,111 @@ +import type { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { Card } from "@/components/ui/card"; +import { TopNav } from "@/components/navigation/top-nav"; +import { prisma } from "@/lib/db/prisma"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { getAppointmentById } from "@/services/scheduling/appointment.service"; +import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization"; +import { ownerRescheduleAppointmentAction } from "@/features/owner/actions/appointments"; +import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker"; + +export const metadata: Metadata = { title: "Reschedule / Reassign" }; +export const dynamic = "force-dynamic"; + +const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]); + +/** + * Also the owner's appointment-reassignment surface — BarberDateTimePicker + * already lets the caller choose a different eligible barber as part of + * picking a new slot, so "reassign to another barber" and "reschedule to a + * new time" are the same underlying operation here, not two separate + * flows. See docs/phase-6-summary.md, "Appointment reassignment approach". + */ +export default async function OwnerRescheduleAppointmentPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canManage = await hasPermission(session.user.id, Permission.MANAGE_ALL_APPOINTMENTS); + if (!canManage) redirect("/staff"); + + const owner = await prisma.staffProfile.findUnique({ + where: { userId: session.user.id }, + include: { business: { select: { id: true, timezone: true } } }, + }); + if (!owner) redirect("/staff"); + + const { id } = await params; + const appointment = await getAppointmentById(prisma, id); + if (!appointment) notFound(); + + try { + assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId }); + } catch (error) { + if (error instanceof StaffPermissionDeniedError) notFound(); + throw error; + } + + if (NON_RESCHEDULABLE.has(appointment.status)) { + redirect(`/owner/appointments/${id}`); + } + + async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) { + "use server"; + const result = await ownerRescheduleAppointmentAction({ + appointmentId: id, + staffId: selection.staffId, + startAt: selection.startAt, + }); + if (result.success) { + redirect(`/owner/appointments/${id}`); + } + return result; + } + + return ( +
+ + + +
+

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

+

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

+
+
+ +
+ s.serviceId)} + initialStaffId={appointment.staffId} + onContinue={handleContinue} + continueLabel="Confirm" + /> +
+
+ ); +} diff --git a/src/components/navigation/bottom-nav.tsx b/src/components/navigation/bottom-nav.tsx index f41648b..65c3601 100644 --- a/src/components/navigation/bottom-nav.tsx +++ b/src/components/navigation/bottom-nav.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { CalendarCheck, CalendarDays, House, Scissors, Settings, User, Users } from "lucide-react"; +import { CalendarCheck, CalendarDays, ChartLine, House, Scissors, Settings, User, Users } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils/cn"; @@ -23,6 +23,7 @@ export const NAV_ICON_MAP = { scissors: Scissors, users: Users, settings: Settings, + "chart-line": ChartLine, } satisfies Record; export type BottomNavIconName = keyof typeof NAV_ICON_MAP; diff --git a/src/components/navigation/staff-shell.tsx b/src/components/navigation/staff-shell.tsx index 025c870..1c86863 100644 --- a/src/components/navigation/staff-shell.tsx +++ b/src/components/navigation/staff-shell.tsx @@ -1,14 +1,29 @@ import { BottomNav } from "./bottom-nav"; import { StaffSidebarNav } from "./staff-sidebar-nav"; +import type { BottomNavItem } from "./bottom-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 }) { +/** + * Shared by (staff) and (owner) — see docs/implementation-plan.md §3: owner + * is a permission on a staff account, not a separate shell. `items` + * defaults to the staff nav; (owner) passes OWNER_NAV_ITEMS instead (see + * staff-nav-items.ts) — same shell, same sidebar/bottom-nav components, + * different item set, per the Phase 6 brief's own nav requirement. + */ +export function StaffShell({ + children, + items = STAFF_NAV_ITEMS, + footerLink, +}: { + children: React.ReactNode; + items?: BottomNavItem[]; + footerLink?: { href: string; label: string }; +}) { return (
- + {children} - +
); } diff --git a/src/components/navigation/staff-sidebar-nav.tsx b/src/components/navigation/staff-sidebar-nav.tsx index 194d2b3..00ea6db 100644 --- a/src/components/navigation/staff-sidebar-nav.tsx +++ b/src/components/navigation/staff-sidebar-nav.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Scissors } from "lucide-react"; +import { ArrowLeftRight, Scissors } from "lucide-react"; import { cn } from "@/lib/utils/cn"; import { NAV_ICON_MAP } from "./bottom-nav"; import type { BottomNavItem } from "./bottom-nav"; @@ -15,7 +15,14 @@ import type { BottomNavItem } from "./bottom-nav"; * 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[] }) { +export function StaffSidebarNav({ + items, + footerLink, +}: { + items: BottomNavItem[]; + /** Owner sidebar's explicit "back to barber tools" — see the brief's "clear access back to barber tools". Omitted on the staff sidebar itself. */ + footerLink?: { href: string; label: string }; +}) { const pathname = usePathname(); return ( @@ -27,7 +34,7 @@ export function StaffSidebarNav({ items }: { items: BottomNavItem[] }) {