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.
This commit is contained in:
Stefan Lazic committed 2026-08-03 12:56:55 +01:00
1 parent ffee9d6754
commit ec5583f972
73 files changed
+8400 -53

No files matched your search

+174
View File
@@ -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 `<Link>`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.
+331
View File
@@ -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 <p> 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();
});
});
@@ -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;
@@ -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;
+78 -8
View File
@@ -47,12 +47,20 @@ enum Permission {
MANAGE_LOYALTY_PROGRAMME MANAGE_LOYALTY_PROGRAMME
MANAGE_NOTIFICATION_SETTINGS MANAGE_NOTIFICATION_SETTINGS
VIEW_ALL_STAFF_APPOINTMENTS 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 MANAGE_OWN_APPOINTMENTS
CHECK_IN_CUSTOMERS CHECK_IN_CUSTOMERS
MANAGE_LOYALTY_STAMPS MANAGE_LOYALTY_STAMPS
MANAGE_OWN_AVAILABILITY MANAGE_OWN_AVAILABILITY
VIEW_CUSTOMER_DIRECTORY VIEW_CUSTOMER_DIRECTORY
BOOK_ON_BEHALF_OF_CUSTOMER 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 { enum StaffStatus {
@@ -164,6 +172,7 @@ model Business {
roles Role[] roles Role[]
appointments Appointment[] appointments Appointment[]
loyaltyProgramme LoyaltyProgramme? loyaltyProgramme LoyaltyProgramme?
staffInvitations StaffInvitation[]
} }
/// Split from Business (rather than address fields on Business itself) so /// Split from Business (rather than address fields on Business itself) so
@@ -296,6 +305,8 @@ model User {
cancelledAppointments Appointment[] @relation("AppointmentCancelledBy") cancelledAppointments Appointment[] @relation("AppointmentCancelledBy")
/// Whoever initiated a booking — see Appointment.createdByUserId. /// Whoever initiated a booking — see Appointment.createdByUserId.
createdAppointments Appointment[] @relation("AppointmentCreatedBy") createdAppointments Appointment[] @relation("AppointmentCreatedBy")
/// Owner-side: invitations this user has sent (see StaffInvitation).
sentStaffInvitations StaffInvitation[] @relation("StaffInvitationInvitedBy")
} }
model Account { model Account {
@@ -398,6 +409,10 @@ model StaffProfile {
preferredByCustomers CustomerProfile[] @relation("PreferredBarber") preferredByCustomers CustomerProfile[] @relation("PreferredBarber")
authoredLoyaltyTransactions LoyaltyTransaction[] @relation("LoyaltyTransactionAuthor") authoredLoyaltyTransactions LoyaltyTransaction[] @relation("LoyaltyTransactionAuthor")
authoredCustomerNotes CustomerNote[] @relation("CustomerNoteAuthor") 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]) @@index([businessId])
} }
@@ -434,6 +449,37 @@ model RolePermission {
@@unique([roleId, permission]) @@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 // Catalog
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -479,9 +525,10 @@ model Service {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
staff StaffService[] staff StaffService[]
appointmentServices AppointmentService[] appointmentServices AppointmentService[]
loyaltyEligibleFor LoyaltyEligibleService[] loyaltyEligibleFor LoyaltyEligibleService[]
loyaltyProgrammesUsingAsFreeReward LoyaltyProgramme[]
@@index([businessId]) @@index([businessId])
@@index([categoryId]) @@index([categoryId])
@@ -605,6 +652,14 @@ model Appointment {
notes String? @db.Text 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 /// Cancellation is a status transition, never a delete — these three
/// fields are only ever set together, when status becomes CANCELLED. /// fields are only ever set together, when status becomes CANCELLED.
cancellationReason String? cancellationReason String?
@@ -623,9 +678,13 @@ model Appointment {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
services AppointmentService[] services AppointmentService[]
loyaltyTransactions LoyaltyTransaction[] loyaltyTransactions LoyaltyTransaction[]
notifications Notification[] 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([staffId, startAt])
@@index([businessId, startAt]) @@index([businessId, startAt])
@@ -671,6 +730,12 @@ model LoyaltyProgramme {
rewardLabel String? rewardLabel String?
maxRewardValue Decimal? @db.Decimal(10, 2) 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) autoApplyReward Boolean @default(true)
requiresStaffApproval Boolean @default(false) requiresStaffApproval Boolean @default(false)
@@ -774,8 +839,13 @@ model LoyaltyReward {
status RewardStatus @default(AVAILABLE) status RewardStatus @default(AVAILABLE)
appliedToAppointmentId String? appliedToAppointmentId String?
redeemedAt DateTime? appliedToAppointment Appointment? @relation(fields: [appliedToAppointmentId], references: [id], onDelete: SetNull)
expiresAt DateTime?
redeemedAt DateTime?
redeemedByStaffId String?
redeemedByStaff StaffProfile? @relation("LoyaltyRewardRedeemedBy", fields: [redeemedByStaffId], references: [id], onDelete: SetNull)
expiresAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
+236
View File
@@ -27,6 +27,25 @@ function minutesFromNow(minutes: number): Date {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter }); 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<void> {
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[] = [ const OWNER_PERMISSIONS: Permission[] = [
Permission.MANAGE_BUSINESS_PROFILE, Permission.MANAGE_BUSINESS_PROFILE,
Permission.MANAGE_BOOKING_RULES, Permission.MANAGE_BOOKING_RULES,
@@ -35,12 +54,14 @@ const OWNER_PERMISSIONS: Permission[] = [
Permission.MANAGE_LOYALTY_PROGRAMME, Permission.MANAGE_LOYALTY_PROGRAMME,
Permission.MANAGE_NOTIFICATION_SETTINGS, Permission.MANAGE_NOTIFICATION_SETTINGS,
Permission.VIEW_ALL_STAFF_APPOINTMENTS, Permission.VIEW_ALL_STAFF_APPOINTMENTS,
Permission.MANAGE_ALL_APPOINTMENTS,
Permission.MANAGE_OWN_APPOINTMENTS, Permission.MANAGE_OWN_APPOINTMENTS,
Permission.CHECK_IN_CUSTOMERS, Permission.CHECK_IN_CUSTOMERS,
Permission.MANAGE_LOYALTY_STAMPS, Permission.MANAGE_LOYALTY_STAMPS,
Permission.MANAGE_OWN_AVAILABILITY, Permission.MANAGE_OWN_AVAILABILITY,
Permission.VIEW_CUSTOMER_DIRECTORY, Permission.VIEW_CUSTOMER_DIRECTORY,
Permission.BOOK_ON_BEHALF_OF_CUSTOMER, Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
Permission.VIEW_REPORTING,
]; ];
const BARBER_PERMISSIONS: Permission[] = [ const BARBER_PERMISSIONS: Permission[] = [
@@ -123,6 +144,7 @@ async function main() {
permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) }, permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) },
}, },
}); });
await syncRolePermissions(ownerRole.id, OWNER_PERMISSIONS);
const barberRole = await prisma.role.upsert({ const barberRole = await prisma.role.upsert({
where: { businessId_name: { businessId: business.id, name: "Barber" } }, where: { businessId_name: { businessId: business.id, name: "Barber" } },
@@ -134,6 +156,7 @@ async function main() {
permissions: { create: BARBER_PERMISSIONS.map((permission) => ({ permission })) }, permissions: { create: BARBER_PERMISSIONS.map((permission) => ({ permission })) },
}, },
}); });
await syncRolePermissions(barberRole.id, BARBER_PERMISSIONS);
const category = await prisma.serviceCategory.upsert({ const category = await prisma.serviceCategory.upsert({
where: { id: `${business.id}-senior-barber` }, 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:", { console.log("Seed complete:", {
business: business.name, business: business.name,
roles: [ownerRole.name, barberRole.name], roles: [ownerRole.name, barberRole.name],
+51
View File
@@ -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 <InvalidInvitation message="This invitation link is missing its token." />;
}
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 <InvalidInvitation message={message} />;
}
return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Welcome, {check.invitation.staff.user.name}</h1>
<p className="text-muted mb-10">Finish setting up your staff account.</p>
<AcceptInvitationForm token={token} businessName={check.invitation.staff.business.name} />
</div>
);
}
function InvalidInvitation({ message }: { message: string }) {
return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Invitation Not Available</h1>
<p className="text-muted mb-10">{message}</p>
<Link href="/sign-in" className="border-ink text-ink border-b font-bold text-sm">
Go to Sign In
</Link>
</div>
);
}
+6 -1
View File
@@ -3,6 +3,7 @@ import { auth } from "@/auth";
import { StaffShell } from "@/components/navigation/staff-shell"; import { StaffShell } from "@/components/navigation/staff-shell";
import { hasPermission } from "@/lib/auth/permissions"; import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums"; 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 * 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"); redirect("/staff");
} }
return <StaffShell>{children}</StaffShell>; return (
<StaffShell items={OWNER_NAV_ITEMS} footerLink={{ href: "/staff", label: "Barber Tools" }}>
{children}
</StaffShell>
);
} }
@@ -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<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
const RESCHEDULABLE = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
export default async function 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 (
<div className="pb-12">
<TopNav title="Appointment Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="space-y-6 p-6">
<div className="flex items-center justify-between">
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
Ref: {appointment.id.slice(-8)}
</span>
</div>
<div className="border-line space-y-1 border-t pt-4">
<Link href={`/owner/team/${appointment.staffId}`} className="text-muted text-xs font-bold tracking-widest uppercase underline">
{appointment.staff.user.name ?? "Staff"}
</Link>
<Link href={`/staff/clients/${appointment.customerId}`} className="block text-lg font-bold underline">
{appointment.customer.firstName} {appointment.customer.lastName}
</Link>
<p className="text-muted text-sm">
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
<div className="flex flex-wrap gap-3">
{appointment.customer.phone && (
<a
href={`tel:${appointment.customer.phone}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
</a>
)}
<a
href={`mailto:${appointment.customer.email}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
</a>
</div>
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Services</span>
<p className="font-bold">{appointment.services.map((s) => s.service.name).join(" + ")}</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Date</span>
<p className="font-bold">
{appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: tz })}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Time</span>
<p className="font-bold">
{appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })} –{" "}
{appointment.endAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Duration</span>
<p className="font-bold">{totalDuration} min</p>
</div>
</div>
<div className="border-line border-t pt-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted">Subtotal</span>
<span className="font-bold">£{subtotal.toFixed(2)}</span>
</div>
{discount > 0 && (
<div className="text-accent-loyalty flex items-center justify-between">
<span>Loyalty reward</span>
<span className="font-bold">-£{discount.toFixed(2)}</span>
</div>
)}
<div className="border-line mt-1 flex items-center justify-between border-t pt-1">
<span className="font-bold">Total</span>
<span className="font-bold">£{total.toFixed(2)}</span>
</div>
</div>
{appointment.notes && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Booking Notes</span>
<p className="text-muted mt-1 text-sm italic">{appointment.notes}</p>
</div>
)}
{notes.length > 0 && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Customer Notes</span>
<div className="mt-2 space-y-2">
{notes.map((n) => (
<p key={n.id} className="text-muted text-xs">
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
</p>
))}
</div>
</div>
)}
{loyalty?.isEnabled && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Loyalty</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
{loyalty.availableReward && <span className="text-accent-loyalty"> · Reward available</span>}
</p>
{loyaltyTransactions.length > 0 && (
<ul className="mt-2 space-y-1">
{loyaltyTransactions.map((t) => (
<li key={t.id} className="text-muted text-xs">
{t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} — {t.type.replace(/_/g, " ").toLowerCase()}
{t.reason ? ` (${t.reason})` : ""}
</li>
))}
</ul>
)}
</div>
)}
{appointment.status === "CANCELLED" && (
<div className="border-line border-t pt-4 text-sm">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Cancellation</span>
<p className="text-muted mt-1">
Cancelled {appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: tz })}
{cancelledByUser ? ` by ${cancelledByUser.name}` : ""}
</p>
{appointment.cancellationReason && (
<p className="text-muted mt-1 italic">&ldquo;{appointment.cancellationReason}&rdquo;</p>
)}
</div>
)}
</Card>
<div className="flex flex-wrap gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics`}
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{RESCHEDULABLE.has(appointment.status) && (
<Link
href={`/owner/appointments/${appointment.id}/reschedule`}
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
>
Reschedule / Reassign
</Link>
)}
{appointment.status === "COMPLETED" && <BookFollowUpButton appointmentId={appointment.id} />}
</div>
<AppointmentStatusActions
appointmentId={appointment.id}
status={appointment.status}
actions={ownerAppointmentActions}
/>
<div className="flex flex-wrap gap-3">
{loyalty?.isEnabled && <AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />}
{canRedeemReward && loyalty.availableReward && (
<RedeemRewardButton
appointmentId={appointment.id}
rewardId={loyalty.availableReward.id}
rewardLabel={loyalty.availableReward.label}
subtotal={subtotal}
/>
)}
</div>
</main>
</div>
);
}
+310
View File
@@ -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<string, AppointmentStatus> = {
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 (
<div className="pb-6">
<div className="flex items-center justify-between px-6 pt-12">
<h1 className="text-2xl font-bold tracking-tight">Calendar</h1>
<div className="flex gap-2">
{selectedStaff && <OwnerBlockTimeButton staffId={selectedStaff.id} staffName={selectedStaff.user.name ?? "Staff"} date={date} />}
<Button asChild size="sm">
{/* /staff/book already lets whoever is signed in book with any eligible barber — the
owner is a staff member too, so this is the same "book for a customer" flow Phase 5
built, not a duplicate. See docs/phase-6-summary.md, "Owner appointment access". */}
<Link href="/staff/book">
<Plus className="h-4 w-4" aria-hidden="true" />
New
</Link>
</Button>
</div>
</div>
<DateStrip
selectedDate={date}
businessTimezone={businessTimezone}
basePath="/owner/calendar"
view={view}
extraParams={selectedStaff ? { staffId: selectedStaff.id } : undefined}
/>
<div className="mt-4 flex flex-wrap items-center gap-2 px-6">
<Link
href={`/owner/calendar?date=${date}&view=${view}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
!selectedStaff ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
All Staff
</Link>
{allStaff.map((s) => (
<Link
key={s.id}
href={`/owner/calendar?date=${date}&view=${view}&staffId=${s.id}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
selectedStaff?.id === s.id ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
{s.user.name}
</Link>
))}
</div>
<div className="mt-4 flex gap-2 px-6">
<Link
href={`/owner/calendar?date=${date}&view=day${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
view === "day" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Day
</Link>
<Link
href={`/owner/calendar?date=${date}&view=agenda${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
view === "agenda" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Agenda
</Link>
</div>
<div className="mt-4">
{view === "day" ? (
staffToRender.length === 0 ? (
<div className="px-6">
<EmptyState icon={CalendarX2} title="No active staff" description="Add a staff member from Team." />
</div>
) : (
<div className="flex gap-4 overflow-x-auto px-6 pb-4 lg:px-0">
{staffToRender.map((s) => {
const column = buildColumnFor(s.id);
return (
<div key={s.id} className="w-[320px] shrink-0 lg:w-auto lg:flex-1">
<p className="text-muted mb-2 px-6 text-xs font-bold tracking-widest uppercase lg:px-0">
{s.user.name}
</p>
{column.wholeDayOff ? (
<div className="px-6 lg:px-0">
<EmptyState icon={CalendarX2} title="Off this day" />
</div>
) : (
<DayCalendar
appointments={column.staffAppointments}
blocks={column.blocks}
workingWindow={column.workingWindow}
currentTimeMinutes={currentTimeMinutes}
appointmentHrefBase="/owner/appointments"
/>
)}
</div>
);
})}
</div>
)
) : appointments.length === 0 ? (
<div className="px-6">
<EmptyState icon={CalendarX2} title="No appointments" description="Nothing scheduled for this day." />
</div>
) : (
<div className="space-y-4 px-6 lg:mx-auto lg:max-w-3xl">
{[...appointments]
.sort((a, b) => a.startAt.getTime() - b.startAt.getTime())
.map((a) => (
<div key={a.id}>
{!selectedStaff && (
<p className="text-muted mb-1 text-[10px] font-bold tracking-widest uppercase">
{a.staff.user.name}
</p>
)}
<AppointmentCard
status={STATUS_MAP[a.status] ?? "confirmed"}
serviceName={a.services.map((s) => 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={
<Link
href={`/owner/appointments/${a.id}`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
View
</Link>
}
/>
</div>
))}
</div>
)}
</div>
</div>
);
}
+184 -4
View File
@@ -1,9 +1,189 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ChartLine } from "lucide-react"; import Link from "next/link";
import { ComingSoon } from "@/components/feedback/coming-soon"; 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 metadata: Metadata = { title: "Business Dashboard" };
export const dynamic = "force-dynamic";
export default function OwnerDashboardPage() { function StatTile({ icon: Icon, label, value, hint }: { icon: typeof Calendar; label: string; value: string | number; hint?: string }) {
return <ComingSoon icon={ChartLine} title="Business Dashboard" phase="Phase 4: Owner Administration" />; return (
<Card className="p-5">
<div className="text-muted mb-2 flex items-center gap-2">
<Icon className="h-4 w-4" aria-hidden="true" />
<span className="text-[10px] font-bold tracking-widest uppercase">{label}</span>
</div>
<p className="text-2xl font-bold">{value}</p>
{hint && <p className="text-muted mt-1 text-xs">{hint}</p>}
</Card>
);
}
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 (
<div className="space-y-6 px-6 pt-12 pb-12">
<div>
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted text-sm">{staff.business.name} — today</p>
</div>
{summary.warnings.length > 0 && (
<section className="space-y-2">
{summary.warnings.map((w, i) => (
<div
key={`${w.code}-${i}`}
role="alert"
className="bg-warning/10 text-warning-foreground border-warning/30 flex items-start gap-3 rounded-jumbo border p-4 text-sm"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<p>{w.message}</p>
</div>
))}
</section>
)}
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile icon={Calendar} label="Today's appointments" value={summary.totals.total} />
<StatTile icon={CheckCheck} label="Completed" value={summary.totals.completed} />
<StatTile icon={CalendarX2} label="Cancelled" value={summary.totals.cancelled} />
<StatTile icon={UserX} label="No-shows" value={summary.totals.noShow} />
</section>
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile icon={TrendingUp} label="In progress now" value={summary.totals.inProgress} />
<StatTile icon={Calendar} label="Still to come today" value={summary.totals.upcoming} />
<StatTile
icon={TrendingUp}
label="Estimated occupancy"
value={summary.occupancy ? `${summary.occupancy.percent}%` : "—"}
hint={summary.occupancy ? "Booked vs. working minutes today — an estimate, not a financial figure." : "No staff working today."}
/>
<StatTile icon={Gift} label="Customers with a reward" value={summary.customersWithAvailableRewards} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<Users className="text-accent h-4 w-4" aria-hidden="true" /> Staff Today
</h2>
{summary.staffWorkingToday.length === 0 ? (
<p className="text-muted text-sm">No one is scheduled to work today.</p>
) : (
<ul className="space-y-2">
{summary.staffWorkingToday.map((s) => (
<li key={s.staffId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-accent text-xs font-bold tracking-widest uppercase">Working</span>
</li>
))}
</ul>
)}
{summary.staffOnTimeOffToday.length > 0 && (
<ul className="border-line mt-3 space-y-2 border-t pt-3">
{summary.staffOnTimeOffToday.map((s) => (
<li key={s.staffId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-muted text-xs font-bold tracking-widest uppercase">
{s.reason ?? "Time off"}
</span>
</li>
))}
</ul>
)}
</Card>
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<TrendingUp className="text-accent h-4 w-4" aria-hidden="true" /> Popular Services
</h2>
<p className="text-muted mb-3 text-xs">Completed visits, last 30 days.</p>
{summary.popularServices.length === 0 ? (
<p className="text-muted text-sm">No completed visits in the last 30 days yet.</p>
) : (
<ul className="space-y-2">
{summary.popularServices.map((s) => (
<li key={s.serviceId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-muted">{s.count}</span>
</li>
))}
</ul>
)}
</Card>
</section>
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<Stamp className="text-accent h-4 w-4" aria-hidden="true" /> Recent Loyalty Activity
</h2>
{summary.recentLoyaltyActivity.length === 0 ? (
<p className="text-muted text-sm">No loyalty activity yet.</p>
) : (
<ul className="space-y-2">
{summary.recentLoyaltyActivity.map((t) => (
<li key={t.id} className="text-muted text-xs">
<span className="text-ink font-bold">
{t.loyaltyAccount.customer.firstName} {t.loyaltyAccount.customer.lastName}
</span>{" "}
— {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}` : ""}
</li>
))}
</ul>
)}
</Card>
<div className="flex flex-wrap gap-3">
<Link
href="/owner/team"
className="rounded-jumbo border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
>
Manage Team
</Link>
<Link
href="/owner/settings/booking-rules"
className="rounded-jumbo bg-ink flex-1 px-5 py-4 text-center text-sm font-bold text-white"
>
Update Booking Rules
</Link>
</div>
</div>
);
} }
+49
View File
@@ -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 (
<div className="px-6 pt-12 pb-12 lg:max-w-3xl">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Services</h1>
<CatalogueManager
categories={categories.map((c) => ({
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,
})),
}))}
/>
</div>
);
}
@@ -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 (
<div className="pb-12">
<TopNav title="Booking Rules" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<BookingRulesForm
initial={{
minNoticeMinutes: rule.minNoticeMinutes,
maxAdvanceDays: rule.maxAdvanceDays,
cancellationWindowHours: rule.cancellationWindowHours,
rescheduleWindowHours: rule.rescheduleWindowHours,
slotIntervalMinutes: rule.slotIntervalMinutes,
bufferBeforeMinutes: rule.bufferBeforeMinutes,
bufferAfterMinutes: rule.bufferAfterMinutes,
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
customerCanChooseStaff: rule.customerCanChooseStaff,
allowGuestBooking: rule.allowGuestBooking,
requireApproval: rule.requireApproval,
}}
/>
</main>
</div>
);
}
@@ -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 (
<div className="pb-12">
<TopNav title="Business Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<BusinessDetailsForm
initial={{
name: business.name,
description: business.description ?? "",
logoUrl: business.logoUrl ?? "",
phone: business.phone ?? "",
email: business.email ?? "",
website: business.website ?? "",
instagramUrl: business.instagramUrl ?? "",
facebookUrl: business.facebookUrl ?? "",
timezone: business.timezone,
currency: business.currency,
locale: business.locale,
addressLine1: location?.addressLine1 ?? "",
addressLine2: location?.addressLine2 ?? "",
city: location?.city ?? "",
region: location?.region ?? "",
postalCode: location?.postalCode ?? "",
country: location?.country ?? "GB",
cancellationPolicy: business.settings?.cancellationPolicy ?? "",
lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "",
bookingTerms: business.settings?.bookingTerms ?? "",
privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "",
}}
/>
</main>
</div>
);
}
@@ -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 (
<div className="pb-12">
<TopNav title="Opening Hours" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<OpeningHoursForm initialRows={hours} />
</main>
</div>
);
}
@@ -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 (
<div className="pb-12">
<TopNav title="Loyalty Programme" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<LoyaltyConfigForm
initialConfig={initialConfig}
initialEligibleServiceIds={programme?.eligibleServices.map((s) => 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" }))}
/>
</main>
</div>
);
}
+56
View File
@@ -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 (
<div className="space-y-6 px-6 pt-12 pb-12">
<h1 className="text-2xl font-bold tracking-tight">Owner Settings</h1>
<ProfileCard name={session.user.name ?? "Owner"} meta={session.user.email} avatarUrl={session.user.image ?? undefined} />
<Link
href="/staff/settings"
className="rounded-jumbo border-line flex items-center gap-3 border bg-white p-5 text-sm font-bold hover:bg-stone"
>
<ScissorsSquare className="text-accent h-5 w-5" aria-hidden="true" />
Switch to Barber Tools
</Link>
<ul className="divide-line rounded-jumbo border-line divide-y border bg-white">
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
<li key={href}>
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
<Icon className="text-muted h-5 w-5" aria-hidden="true" />
{label}
</Link>
</li>
))}
</ul>
<SignOutButton />
</div>
);
}
@@ -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<Weekday, string>;
return (
<div className="pb-12">
<TopNav title={`${target.user.name ?? "Staff"} — Working Hours`} onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<WorkingHoursForm
initialRows={workingHours}
businessHoursLabel={businessHoursLabel}
onSave={(days) => updateStaffWorkingHoursAction(id, days)}
/>
</main>
</div>
);
}
+82
View File
@@ -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 (
<div className="pb-12">
<TopNav title="Staff Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6 lg:max-w-4xl lg:grid lg:max-w-none lg:grid-cols-[320px_1fr] lg:items-start lg:gap-6 lg:space-y-0">
<div className="space-y-4">
<Card className="flex flex-col items-center p-6 text-center">
{target.user.image ? (
// eslint-disable-next-line @next/next/no-img-element -- external avatar URL, no next/image domain config for it
<img src={target.user.image} alt="" className="border-stone mb-4 h-24 w-24 rounded-full border-4 object-cover" />
) : (
<div className="bg-stone text-muted mb-4 flex h-24 w-24 items-center justify-center rounded-full text-2xl font-bold">
{(target.user.name ?? "?").charAt(0)}
</div>
)}
<h2 className="text-xl font-bold">{target.user.name}</h2>
<p className="text-accent mt-1 text-xs font-bold tracking-widest uppercase">
{target.role.name} · {target.status === "ACTIVE" ? "Active" : target.status === "ON_BREAK" ? "On Break" : "Inactive"}
</p>
<p className="text-muted mt-2 text-xs">Joined {target.createdAt.toLocaleDateString("en-GB", { month: "short", year: "numeric" })}</p>
{target.bio && <p className="text-muted mt-3 text-sm">{target.bio}</p>}
</Card>
<div className="grid grid-cols-2 gap-3">
{target.user.phone && (
<a href={`tel:${target.user.phone}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Call
</a>
)}
{target.user.email && (
<a href={`mailto:${target.user.email}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
<Mail className="text-accent h-4 w-4" aria-hidden="true" /> Email
</a>
)}
</div>
</div>
<StaffDetailPanel
staffId={target.id}
currentRoleId={target.roleId}
roles={roles.map((r) => ({ 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}
/>
</main>
</div>
);
}
+37
View File
@@ -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 (
<div className="pb-12">
<TopNav title="Add Staff" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<AddStaffForm roles={roles.map((r) => ({ id: r.id, name: r.name }))} services={services.map((s) => ({ id: s.id, name: s.name }))} />
</main>
</div>
);
}
+91 -5
View File
@@ -1,9 +1,95 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Users } from "lucide-react"; import Link from "next/link";
import { ComingSoon } from "@/components/feedback/coming-soon"; 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() { export default async function OwnerTeamPage({
return <ComingSoon icon={Users} title="Team Management" phase="Phase 4: Owner Administration" />; 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 (
<div className="px-6 pt-12 pb-12 lg:max-w-4xl">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">Team</h1>
<Link
href="/owner/team/add"
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white"
aria-label="Add staff member"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
<form action="/owner/team" method="GET" className="relative mb-4">
<Search className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
<label htmlFor="team-search" className="sr-only">
Search staff by name or email
</label>
<input
id="team-search"
type="search"
name="q"
defaultValue={q}
placeholder="Search by name or email"
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
/>
</form>
<div className="grid gap-3 lg:grid-cols-2">
{staff.length === 0 ? (
<p className="text-muted col-span-full rounded-jumbo border border-line bg-white p-5 text-center text-sm">
No staff match &ldquo;{q}&rdquo;.
</p>
) : (
staff.map((s) => (
<Link
key={s.id}
href={`/owner/team/${s.id}`}
className={cn(
"rounded-jumbo border-line flex items-center gap-4 border bg-white p-5",
s.status === "INACTIVE" && "opacity-60",
)}
>
<div className="bg-stone text-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-sm font-bold">
{(s.user.name ?? "?").charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold">{s.user.name}</p>
<p className="text-muted truncate text-xs">{s.user.email}</p>
<p className="text-accent mt-0.5 text-[10px] font-bold tracking-widest uppercase">
{s.role.name}
{s.status === "INACTIVE" && <span className="text-muted"> · Inactive</span>}
{s.status === "ACTIVE" && s.invitation && !s.invitation.acceptedAt && (
<span className="text-warning-foreground"> · Invite Pending</span>
)}
</p>
</div>
</Link>
))
)}
</div>
</div>
);
} }
@@ -13,6 +13,7 @@ import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/sta
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions"; import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { AddStampButton } from "@/features/staff/components/add-stamp-button"; import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-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"; import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointment Details" }; export const metadata: Metadata = { title: "Appointment Details" };
@@ -74,6 +75,9 @@ export default async function StaffAppointmentDetailsPage({
0, 0,
); );
const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 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 ( return (
<div className="pb-12"> <div className="pb-12">
@@ -163,6 +167,9 @@ export default async function StaffAppointmentDetailsPage({
</span> </span>
<p className="font-bold"> <p className="font-bold">
{totalDuration} min · £{totalPrice.toFixed(2)} {totalDuration} min · £{totalPrice.toFixed(2)}
{discount > 0 && (
<span className="text-accent-loyalty"> (-£{discount.toFixed(2)} reward)</span>
)}
</p> </p>
</div> </div>
</div> </div>
@@ -257,9 +264,19 @@ export default async function StaffAppointmentDetailsPage({
<AppointmentStatusActions appointmentId={appointment.id} status={appointment.status} /> <AppointmentStatusActions appointmentId={appointment.id} status={appointment.status} />
{loyalty?.isEnabled && ( <div className="flex flex-wrap gap-3">
<AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} /> {loyalty?.isEnabled && (
)} <AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />
)}
{canRedeemReward && loyalty?.availableReward && (
<RedeemRewardButton
appointmentId={appointment.id}
rewardId={loyalty.availableReward.id}
rewardLabel={loyalty.availableReward.label}
subtotal={totalPrice}
/>
)}
</div>
</main> </main>
</div> </div>
); );
@@ -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 <div className="bg-stone min-h-screen">{children}</div>;
}
@@ -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 (
<div className="pb-6">
<TopNav title="Reschedule / Reassign" onBack sticky className="bg-stone" />
<Card className="mx-6 mt-4 flex items-center gap-3 p-4">
<div className="flex-1">
<p className="text-sm font-bold">
{appointment.services.map((s) => s.service.name).join(" + ")} for {appointment.customer.firstName}{" "}
{appointment.customer.lastName}
</p>
<p className="text-muted text-xs">
Currently 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,
})}
</p>
</div>
</Card>
<div className="mt-4">
<BarberDateTimePicker
businessId={owner.businessId}
businessTimezone={owner.business.timezone}
serviceIds={appointment.services.map((s) => s.serviceId)}
initialStaffId={appointment.staffId}
onContinue={handleContinue}
continueLabel="Confirm"
/>
</div>
</div>
);
}
+2 -1
View File
@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; 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 type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils/cn"; import { cn } from "@/lib/utils/cn";
@@ -23,6 +23,7 @@ export const NAV_ICON_MAP = {
scissors: Scissors, scissors: Scissors,
users: Users, users: Users,
settings: Settings, settings: Settings,
"chart-line": ChartLine,
} satisfies Record<string, LucideIcon>; } satisfies Record<string, LucideIcon>;
export type BottomNavIconName = keyof typeof NAV_ICON_MAP; export type BottomNavIconName = keyof typeof NAV_ICON_MAP;
+19 -4
View File
@@ -1,14 +1,29 @@
import { BottomNav } from "./bottom-nav"; import { BottomNav } from "./bottom-nav";
import { StaffSidebarNav } from "./staff-sidebar-nav"; import { StaffSidebarNav } from "./staff-sidebar-nav";
import type { BottomNavItem } from "./bottom-nav";
import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items"; 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 ( return (
<div className="min-h-screen bg-stone pb-24 lg:pb-0 lg:pl-56"> <div className="min-h-screen bg-stone pb-24 lg:pb-0 lg:pl-56">
<StaffSidebarNav items={STAFF_NAV_ITEMS} /> <StaffSidebarNav items={items} footerLink={footerLink} />
{children} {children}
<BottomNav items={STAFF_NAV_ITEMS} variant="staff" /> <BottomNav items={items} variant="staff" />
</div> </div>
); );
} }
@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Scissors } from "lucide-react"; import { ArrowLeftRight, Scissors } from "lucide-react";
import { cn } from "@/lib/utils/cn"; import { cn } from "@/lib/utils/cn";
import { NAV_ICON_MAP } from "./bottom-nav"; import { NAV_ICON_MAP } from "./bottom-nav";
import type { BottomNavItem } 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 * to spare, per the brief's explicit call-out that staff desktop needs
* more than the mobile bar simply centered. * 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(); const pathname = usePathname();
return ( return (
@@ -27,7 +34,7 @@ export function StaffSidebarNav({ items }: { items: BottomNavItem[] }) {
<Scissors className="h-5 w-5" aria-hidden="true" /> <Scissors className="h-5 w-5" aria-hidden="true" />
<span className="text-sm font-bold tracking-widest uppercase">NextChair</span> <span className="text-sm font-bold tracking-widest uppercase">NextChair</span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-1 flex-col gap-1">
{items.map(({ href, label, icon }) => { {items.map(({ href, label, icon }) => {
const Icon = NAV_ICON_MAP[icon]; const Icon = NAV_ICON_MAP[icon];
const active = pathname === href || pathname.startsWith(`${href}/`); const active = pathname === href || pathname.startsWith(`${href}/`);
@@ -47,6 +54,15 @@ export function StaffSidebarNav({ items }: { items: BottomNavItem[] }) {
); );
})} })}
</div> </div>
{footerLink && (
<Link
href={footerLink.href}
className="mt-4 flex items-center gap-3 rounded-xl border-t border-white/5 px-3 pt-4 text-sm font-bold text-gray-400 transition-colors hover:text-white"
>
<ArrowLeftRight className="h-4 w-4" aria-hidden="true" />
{footerLink.label}
</Link>
)}
</nav> </nav>
); );
} }
@@ -0,0 +1,47 @@
"use server";
import { signIn } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { acceptInvitation, validateInvitationToken } from "@/services/staff/staff-invitation.service";
import { acceptInvitationSchema } from "@/features/auth/schemas";
export interface AcceptInvitationResult {
success: boolean;
error?: string;
fieldErrors?: Record<string, string[] | undefined>;
}
/** Public — the invited person has no account/session yet. Mirrors registerAction's shape: create the credential, then sign in immediately, same as a fresh registration. */
export async function acceptInvitationAction(token: string, formData: FormData): Promise<AcceptInvitationResult> {
const parsed = acceptInvitationSchema.safeParse({
password: formData.get("password"),
confirmPassword: formData.get("confirmPassword"),
});
if (!parsed.success) {
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
}
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 — try signing in instead."
: "This invitation link isn't valid.";
return { success: false, error: message };
}
const result = await acceptInvitation(prisma, { token, password: parsed.data.password });
if (!result.success) {
return { success: false, error: "Couldn't accept this invitation. Please try again." };
}
await signIn("credentials", {
email: check.invitation.staff.user.email,
password: parsed.data.password,
redirectTo: "/staff",
});
return { success: true };
}
@@ -0,0 +1,69 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { acceptInvitationAction } from "@/features/auth/actions/accept-invitation";
import { acceptInvitationSchema } from "@/features/auth/schemas";
import type { AcceptInvitationInput } from "@/features/auth/schemas";
export function AcceptInvitationForm({ token, businessName }: { token: string; businessName: string }) {
const [formError, setFormError] = useState<string | null>(null);
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<AcceptInvitationInput>({ resolver: zodResolver(acceptInvitationSchema) });
const onSubmit = async (data: AcceptInvitationInput) => {
setFormError(null);
const formData = new FormData();
formData.set("password", data.password);
formData.set("confirmPassword", data.confirmPassword);
const result = await acceptInvitationAction(token, formData);
if (!result.success) {
setFormError(result.error ?? "Something went wrong. Please try again.");
if (result.fieldErrors) {
for (const [field, messages] of Object.entries(result.fieldErrors)) {
if (messages?.[0]) setError(field as keyof AcceptInvitationInput, { message: messages[0] });
}
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<p className="text-muted text-sm">
You&apos;ve been invited to join <span className="text-ink font-bold">{businessName}</span> on NextChair. Set
a password to finish setting up your account.
</p>
<Input
label="Password"
type="password"
placeholder="••••••••"
error={errors.password?.message}
{...register("password")}
/>
<Input
label="Confirm Password"
type="password"
placeholder="••••••••"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
{formError && (
<p role="alert" className="text-danger text-sm font-medium">
{formError}
</p>
)}
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
Set Password &amp; Sign In
</Button>
</form>
);
}
+11
View File
@@ -26,3 +26,14 @@ export const registerSchema = z
path: ["confirmPassword"], path: ["confirmPassword"],
}); });
export type RegisterInput = z.infer<typeof registerSchema>; export type RegisterInput = z.infer<typeof registerSchema>;
export const acceptInvitationSchema = z
.object({
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export type AcceptInvitationInput = z.infer<typeof acceptInvitationSchema>;
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { hasPermission } from "@/lib/auth/permissions";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "../authorization";
import { deactivateStaff, updateStaffAsOwner } from "@/services/staff/staff.service";
import { deleteCategory } from "@/services/catalogue/service-catalogue.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* Real-DB coverage of "a normal barber must not be able to perform owner
* actions" and "owner actions never leak across businesses" — the two
* defense-in-depth rules the Phase 6 brief calls out repeatedly. This
* re-derives permissions from the database exactly as the owner Server
* Actions do (assertOwnerPermission -> hasPermission -> a fresh
* StaffProfile/Role/RolePermission query), rather than trusting a cached
* session claim.
*/
describe("Owner authorization and business isolation (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("hasPermission / assertOwnerPermission", () => {
it("grants MANAGE_STAFF to a staff member whose role holds it", async () => {
const ownerRole = await prisma.role.create({
data: {
businessId: business.businessId,
name: "Owner",
permissions: { create: [{ permission: "MANAGE_STAFF" }] },
},
});
const { userId } = await createTestStaff(business);
await prisma.staffProfile.update({ where: { userId }, data: { roleId: ownerRole.id } });
expect(await hasPermission(userId, "MANAGE_STAFF")).toBe(true);
await expect(assertOwnerPermission(userId, "MANAGE_STAFF")).resolves.toBeUndefined();
});
it("denies MANAGE_STAFF to an ordinary barber whose role doesn't hold it", async () => {
const { userId } = await createTestStaff(business); // createTestStaff's auto-role has no permissions
expect(await hasPermission(userId, "MANAGE_STAFF")).toBe(false);
await expect(assertOwnerPermission(userId, "MANAGE_STAFF")).rejects.toBeInstanceOf(OwnerPermissionDeniedError);
});
it("reflects a permission change immediately — no caching across the role edit", async () => {
const barberRole = await prisma.role.create({ data: { businessId: business.businessId, name: "Barber" } });
const { userId } = await createTestStaff(business);
await prisma.staffProfile.update({ where: { userId }, data: { roleId: barberRole.id } });
expect(await hasPermission(userId, "MANAGE_SERVICES")).toBe(false);
await prisma.rolePermission.create({ data: { roleId: barberRole.id, permission: "MANAGE_SERVICES" } });
expect(await hasPermission(userId, "MANAGE_SERVICES")).toBe(true);
});
});
describe("business isolation", () => {
it("updateStaffAsOwner can't reach a staff member from a different business", async () => {
const otherBusiness = await createTestBusiness();
try {
const { staffId: otherStaffId } = await createTestStaff(otherBusiness);
const result = await updateStaffAsOwner(prisma, {
staffId: otherStaffId,
businessId: business.businessId,
input: { bio: "hijacked" },
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("STAFF_NOT_FOUND");
const unaffected = await prisma.staffProfile.findUniqueOrThrow({ where: { id: otherStaffId } });
expect(unaffected.bio).not.toBe("hijacked");
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
it("deactivateStaff can't reach a staff member from a different business", async () => {
const otherBusiness = await createTestBusiness();
try {
const { staffId: otherStaffId } = await createTestStaff(otherBusiness);
const result = await deactivateStaff(prisma, { staffId: otherStaffId, businessId: business.businessId });
expect(result.success).toBe(false);
const unaffected = await prisma.staffProfile.findUniqueOrThrow({ where: { id: otherStaffId } });
expect(unaffected.status).toBe("ACTIVE");
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
it("deleteCategory can't reach a category from a different business", async () => {
const otherBusiness = await createTestBusiness();
try {
// Move the other business's fixture category to be empty so only
// business-scoping (not the has-services guard) is under test.
await prisma.service.deleteMany({ where: { businessId: otherBusiness.businessId } });
const result = await deleteCategory(prisma, {
categoryId: otherBusiness.categoryId,
businessId: business.businessId,
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("CATEGORY_NOT_FOUND");
const stillExists = await prisma.serviceCategory.findUnique({ where: { id: otherBusiness.categoryId } });
expect(stillExists).not.toBeNull();
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
});
});
@@ -0,0 +1,24 @@
import type { AppointmentActionSet } from "@/features/staff/components/appointment-status-actions";
import {
ownerCancelAppointmentAction,
ownerCheckInAppointmentAction,
ownerCompleteAppointmentAction,
ownerConfirmAppointmentAction,
ownerMarkNoShowAction,
ownerStartAppointmentAction,
} from "./appointments";
/**
* A plain object assembling the owner Server Actions into the shape
* AppointmentStatusActions expects — kept in its own (non-"use server")
* module because a "use server" file may only export async functions, not
* an object literal, even one whose values are all Server Actions.
*/
export const ownerAppointmentActions: AppointmentActionSet = {
confirm: ownerConfirmAppointmentAction,
checkIn: ownerCheckInAppointmentAction,
start: ownerStartAppointmentAction,
complete: ownerCompleteAppointmentAction,
markNoShow: ownerMarkNoShowAction,
cancel: ownerCancelAppointmentAction,
};
+178
View File
@@ -0,0 +1,178 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
import {
checkIn,
complete,
confirm,
getAppointmentById,
markNoShow,
start,
} from "@/services/scheduling/appointment.service";
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import type { StaffAppointmentActionResult } from "@/features/staff/actions/appointment-actions";
/**
* Owner equivalents of src/features/staff/actions/appointment-actions.ts —
* same AppointmentService/BookingService/LoyaltyService calls, but scoped
* to "any appointment in the owner's own business" (assertAppointmentInBusiness)
* behind a MANAGE_ALL_APPOINTMENTS permission check, instead of "my own
* appointments only". Barber permissions are untouched; this is a
* parallel, explicit path, not a weakening of assertOwnAppointment — see
* docs/phase-6-summary.md, "Permission and business-isolation rules".
*/
async function loadOwnerAppointment(appointmentId: string) {
const session = await auth();
if (!session?.user) {
return { owner: null, appointment: null, error: "You must be signed in." } as const;
}
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_ALL_APPOINTMENTS);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) {
return { owner: null, appointment: null, error: error.message } as const;
}
throw error;
}
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!owner) return { owner: null, appointment: null, error: "You must be signed in as staff." } as const;
const appointment = await getAppointmentById(prisma, appointmentId);
if (!appointment) return { owner, appointment: null, error: "Appointment not found." } as const;
try {
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
} catch (error) {
if (error instanceof StaffPermissionDeniedError) {
return { owner, appointment: null, error: error.message } as const;
}
throw error;
}
return { owner, appointment, error: null } as const;
}
function friendlyTransitionMessage(code?: string): string {
if (code === "INVALID_STATUS_TRANSITION") {
return "This appointment can't move to that status right now — someone may have already updated it.";
}
if (code === "APPOINTMENT_NOT_FOUND") return "We couldn't find that appointment.";
return "Something went wrong. Please try again.";
}
export async function ownerConfirmAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnerAppointment(appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await confirm(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
export async function ownerCheckInAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnerAppointment(appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await checkIn(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
export async function ownerStartAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnerAppointment(appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await start(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
export async function ownerCompleteAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
const { owner, appointment, error } = await loadOwnerAppointment(appointmentId);
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
const result = await complete(prisma, appointmentId);
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
const loyaltyResult = await awardStampForCompletedAppointment(prisma, { appointmentId, staffId: appointment.staffId });
return { success: true, loyaltyStampAwarded: loyaltyResult.success && !loyaltyResult.alreadyAwarded };
}
export async function ownerMarkNoShowAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnerAppointment(appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await markNoShow(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
export async function ownerCancelAppointmentAction(params: {
appointmentId: string;
reason?: string;
}): Promise<StaffAppointmentActionResult> {
const { owner, appointment, error } = await loadOwnerAppointment(params.appointmentId);
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
const result = await cancelBooking(prisma, {
appointmentId: params.appointmentId,
cancelledByUserId: owner.userId,
reason: params.reason,
});
return result.success
? { success: true }
: { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
}
export interface OwnerRescheduleResult {
success: boolean;
error?: string;
}
/** Reassignment is just a reschedule with a different staffId and the same slot — see docs/phase-6-summary.md, "Appointment reassignment approach". Also covers a genuine date/time reschedule when startAt changes. */
export async function ownerRescheduleAppointmentAction(params: {
appointmentId: string;
staffId?: string;
startAt: string;
}): Promise<OwnerRescheduleResult> {
const { owner, appointment, error } = await loadOwnerAppointment(params.appointmentId);
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
const previousStaffId = appointment.staffId;
const result = await rescheduleBooking(prisma, {
appointmentId: params.appointmentId,
staffId: params.staffId,
startAt: new Date(params.startAt),
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this appointment." };
}
if (params.staffId && params.staffId !== previousStaffId) {
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "APPOINTMENT_REASSIGNED",
entityType: "Appointment",
entityId: params.appointmentId,
metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId },
});
}
return { success: true };
}
@@ -0,0 +1,43 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { updateBookingRule } from "@/services/scheduling/booking-rule.service";
import type { BookingRuleInput } from "@/services/scheduling/booking-rule.service";
export interface BookingRuleActionResult {
success: boolean;
error?: string;
}
export async function updateBookingRuleAction(input: BookingRuleInput): Promise<BookingRuleActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { success: false, error: error.message };
throw error;
}
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!owner) return { success: false, error: "You must be signed in as staff." };
const result = await updateBookingRule(prisma, { businessId: owner.businessId, input });
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save booking rules." };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "BOOKING_RULES_UPDATED",
entityType: "BookingRule",
entityId: owner.businessId,
});
return { success: true };
}
+121
View File
@@ -0,0 +1,121 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import {
setOpeningHours,
updateBusinessProfile,
updateBusinessSettings,
} from "@/services/business/business.service";
import type { BusinessLocationInput, BusinessProfileInput, BusinessSettingsInput, OpeningHoursDayInput } from "@/services/business/business.service";
export interface BusinessActionResult {
success: boolean;
error?: string;
}
async function requireBusinessManager() {
const session = await auth();
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
throw error;
}
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff, error: null } as const;
}
export async function updateBusinessProfileAction(params: {
profile: BusinessProfileInput;
location: BusinessLocationInput;
}): Promise<BusinessActionResult> {
const { staff, error } = await requireBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
const result = await updateBusinessProfile(prisma, { businessId: staff.businessId, ...params });
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save business details." };
}
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_PROFILE_UPDATED",
entityType: "Business",
entityId: staff.businessId,
metadata: { name: params.profile.name },
});
return { success: true };
}
export async function updateBusinessSettingsAction(settings: BusinessSettingsInput): Promise<BusinessActionResult> {
const { staff, error } = await requireBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
await updateBusinessSettings(prisma, { businessId: staff.businessId, settings });
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_SETTINGS_UPDATED",
entityType: "BusinessSettings",
entityId: staff.businessId,
});
return { success: true };
}
export interface OpeningHoursActionResult {
success: boolean;
error?: string;
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string; staffName: string }[];
}
export async function updateOpeningHoursAction(
days: OpeningHoursDayInput[],
acknowledgeConflicts?: boolean,
): Promise<OpeningHoursActionResult> {
const { staff, error } = await requireBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
const business = await prisma.business.findUnique({ where: { id: staff.businessId }, select: { timezone: true } });
if (!business) return { success: false, error: "Business not found." };
const result = await setOpeningHours(prisma, {
businessId: staff.businessId,
businessTimezone: business.timezone,
days,
acknowledgeConflicts,
});
if (!result.success) {
return {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't save opening hours.",
conflicts: result.conflicts.map((c) => ({
id: c.id,
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
serviceNames: c.services.map((s) => s.service.name).join(" + "),
startAt: c.startAt.toISOString(),
staffName: c.staff.user.name ?? "Staff",
})),
};
}
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_HOURS_UPDATED",
entityType: "BusinessHours",
entityId: staff.businessId,
});
return { success: true };
}
+79
View File
@@ -0,0 +1,79 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { createTimeOff } from "@/services/staff/staff.service";
export interface OwnerBlockTimeConflict {
id: string;
customerName: string;
serviceNames: string;
startAt: string;
}
export interface OwnerBlockTimeResult {
success: boolean;
error?: string;
conflicts?: OwnerBlockTimeConflict[];
}
/** Owner variant of Phase 5's blockTimeAction — same underlying StaffService.createTimeOff, just targeting any staff member in the business rather than only the caller's own schedule. See docs/phase-5-summary.md for why Block Time is a partial-day TimeOff row, not a separate model. */
export async function ownerBlockTimeAction(input: {
staffId: string;
date: string;
startMinutes: number;
endMinutes: number;
reason: string;
acknowledgeConflicts?: boolean;
}): Promise<OwnerBlockTimeResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { success: false, error: error.message };
throw error;
}
const owner = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { timezone: true } } },
});
if (!owner) return { success: false, error: "You must be signed in as staff." };
const target = await prisma.staffProfile.findFirst({
where: { id: input.staffId, businessId: owner.businessId },
});
if (!target) return { success: false, error: "Staff member not found." };
const result = await createTimeOff(prisma, {
staffId: input.staffId,
businessTimezone: owner.business.timezone,
input: {
startDate: input.date,
endDate: input.date,
reason: input.reason.trim() || "Blocked",
startMinutes: input.startMinutes,
endMinutes: input.endMinutes,
},
acknowledgeConflicts: input.acknowledgeConflicts,
});
if (!result.success) {
return {
success: false,
error: result.validation.issues[0]?.message ?? "Something went wrong. Please try again.",
conflicts: result.conflicts?.map((c) => ({
id: c.id,
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
serviceNames: c.services.map((s) => s.service.name).join(" + "),
startAt: c.startAt.toISOString(),
})),
};
}
return { success: true };
}
+144
View File
@@ -0,0 +1,144 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import {
createCategory,
createService,
deleteCategory,
reorderCategories,
reorderServices,
setCategoryActive,
setServiceActive,
updateCategory,
updateService,
} from "@/services/catalogue/service-catalogue.service";
import type { CategoryInput, ServiceInput } from "@/services/catalogue/service-catalogue.service";
export interface CatalogueActionResult {
success: boolean;
error?: string;
}
async function requireServiceManager() {
const session = await auth();
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_SERVICES);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
throw error;
}
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff, error: null } as const;
}
export async function createCategoryAction(input: CategoryInput): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await createCategory(prisma, { businessId: owner.businessId, input });
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create category." };
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_CREATED", entityType: "ServiceCategory", entityId: result.category.id });
return { success: true };
}
export async function updateCategoryAction(categoryId: string, input: CategoryInput): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await updateCategory(prisma, { categoryId, businessId: owner.businessId, input });
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update category." };
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_UPDATED", entityType: "ServiceCategory", entityId: categoryId });
return { success: true };
}
export async function setCategoryActiveAction(categoryId: string, isActive: boolean): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await setCategoryActive(prisma, { categoryId, businessId: owner.businessId, isActive });
if (!result.success) return { success: false, error: "Category not found." };
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: isActive ? "CATEGORY_ACTIVATED" : "CATEGORY_DEACTIVATED",
entityType: "ServiceCategory",
entityId: categoryId,
});
return { success: true };
}
export async function deleteCategoryAction(categoryId: string): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await deleteCategory(prisma, { categoryId, businessId: owner.businessId });
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't delete category." };
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_DELETED", entityType: "ServiceCategory", entityId: categoryId });
return { success: true };
}
export async function reorderCategoriesAction(orderedCategoryIds: string[]): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
await reorderCategories(prisma, { businessId: owner.businessId, orderedCategoryIds });
return { success: true };
}
export async function createServiceAction(input: ServiceInput): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await createService(prisma, { businessId: owner.businessId, input });
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create service." };
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "SERVICE_CREATED", entityType: "Service", entityId: result.service.id, metadata: { name: input.name } });
return { success: true };
}
export async function updateServiceAction(serviceId: string, input: ServiceInput): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await updateService(prisma, { serviceId, businessId: owner.businessId, input });
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update service." };
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "SERVICE_UPDATED", entityType: "Service", entityId: serviceId });
return { success: true };
}
export async function setServiceActiveAction(serviceId: string, isActive: boolean): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await setServiceActive(prisma, { serviceId, businessId: owner.businessId, isActive });
if (!result.success) return { success: false, error: "Service not found." };
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: isActive ? "SERVICE_ACTIVATED" : "SERVICE_DEACTIVATED",
entityType: "Service",
entityId: serviceId,
});
return { success: true };
}
export async function reorderServicesAction(categoryId: string, orderedServiceIds: string[]): Promise<CatalogueActionResult> {
const { staff: owner, error } = await requireServiceManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
await reorderServices(prisma, { businessId: owner.businessId, categoryId, orderedServiceIds });
return { success: true };
}
@@ -0,0 +1,61 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { setEligibleServices, setEligibleStaff, updateProgrammeConfig } from "@/services/loyalty/loyalty.service";
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
export interface LoyaltyConfigActionResult {
success: boolean;
error?: string;
}
async function requireLoyaltyManager() {
const session = await auth();
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
throw error;
}
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff, error: null } as const;
}
export async function updateLoyaltyConfigAction(params: {
config: ProgrammeConfigInput;
eligibleServiceIds: string[];
eligibleStaffIds: string[];
}): Promise<LoyaltyConfigActionResult> {
const { staff: owner, error } = await requireLoyaltyManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await updateProgrammeConfig(prisma, { businessId: owner.businessId, input: params.config });
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save loyalty settings." };
}
const programme = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: owner.businessId } });
await Promise.all([
setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: params.eligibleServiceIds }),
setEligibleStaff(prisma, { loyaltyProgrammeId: programme.id, staffIds: params.eligibleStaffIds }),
]);
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "LOYALTY_CONFIG_UPDATED",
entityType: "LoyaltyProgramme",
entityId: programme.id,
metadata: { isEnabled: params.config.isEnabled, rewardType: params.config.rewardType },
});
return { success: true };
}
+58
View File
@@ -0,0 +1,58 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { hasPermission } from "@/lib/auth/permissions";
import { redeemReward } from "@/services/loyalty/loyalty.service";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
export interface RedeemRewardResult {
success: boolean;
error?: string;
discountAmount?: string;
}
/**
* Redemption is an operational action a permitted barber does day to day
* (not "configure the loyalty programme"), so it's gated on
* MANAGE_LOYALTY_STAMPS — the same permission Phase 5 already grants to
* the Barber role for adding/correcting stamps — rather than
* MANAGE_LOYALTY_PROGRAMME, which is owner-only configuration. Both Owner
* and Barber roles hold MANAGE_LOYALTY_STAMPS in the seed. See
* docs/phase-6-summary.md, "Loyalty configuration and redemption approach".
*/
export async function redeemRewardAction(params: {
rewardId: string;
appointmentId: string;
}): Promise<RedeemRewardResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
const allowed = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_STAMPS);
if (!allowed) return { success: false, error: "You don't have permission to do that." };
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) return { success: false, error: "You must be signed in as staff." };
const result = await redeemReward(prisma, {
rewardId: params.rewardId,
appointmentId: params.appointmentId,
staffId: staff.id,
businessId: staff.businessId,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't redeem this reward." };
}
await writeAuditLog(prisma, {
actorUserId: session.user.id,
action: "LOYALTY_REWARD_REDEEMED",
entityType: "LoyaltyReward",
entityId: params.rewardId,
metadata: { appointmentId: params.appointmentId, discountAmount: result.discountAmount },
});
return { success: true, discountAmount: result.discountAmount };
}
+230
View File
@@ -0,0 +1,230 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import {
createStaffProfile,
deactivateStaff,
reactivateStaff,
setStaffServiceEligibility,
setWorkingHours,
updateStaffAsOwner,
} from "@/services/staff/staff.service";
import type { CreateStaffInput, UpdateStaffAsOwnerInput, WorkingHoursDayInput } from "@/services/staff/staff.service";
import { createInvitation, regenerateInvitation } from "@/services/staff/staff-invitation.service";
async function requireStaffManager() {
const session = await auth();
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
throw error;
}
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff, error: null } as const;
}
export interface AddStaffResult {
success: boolean;
error?: string;
staffId?: string;
inviteToken?: string;
}
export async function addStaffAction(input: CreateStaffInput): Promise<AddStaffResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await createStaffProfile(prisma, { businessId: owner.businessId, input });
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this staff member." };
}
const invitation = await createInvitation(prisma, {
businessId: owner.businessId,
staffId: result.staff.id,
email: input.email.toLowerCase(),
invitedByUserId: owner.userId,
});
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_ADDED",
entityType: "StaffProfile",
entityId: result.staff.id,
metadata: { name: input.name, email: input.email, roleId: input.roleId },
});
return { success: true, staffId: result.staff.id, inviteToken: invitation.token };
}
export interface StaffActionResult {
success: boolean;
error?: string;
}
export async function updateStaffAction(staffId: string, input: UpdateStaffAsOwnerInput): Promise<StaffActionResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await updateStaffAsOwner(prisma, { staffId, businessId: owner.businessId, input });
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this staff member." };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: input.roleId ? "STAFF_ROLE_CHANGED" : "STAFF_UPDATED",
entityType: "StaffProfile",
entityId: staffId,
metadata: { ...(input.roleId ? { roleId: input.roleId } : {}) },
});
return { success: true };
}
export interface DeactivateStaffResult {
success: boolean;
error?: string;
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string }[];
}
export async function deactivateStaffAction(staffId: string, acknowledgeConflicts?: boolean): Promise<DeactivateStaffResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await deactivateStaff(prisma, { staffId, businessId: owner.businessId, acknowledgeConflicts });
if (!result.success) {
return {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't deactivate this staff member.",
conflicts: result.conflicts.map((c) => ({
id: c.id,
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
serviceNames: c.services.map((s) => s.service.name).join(" + "),
startAt: c.startAt.toISOString(),
})),
};
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_DEACTIVATED",
entityType: "StaffProfile",
entityId: staffId,
});
return { success: true };
}
export async function reactivateStaffAction(staffId: string): Promise<StaffActionResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await reactivateStaff(prisma, { staffId, businessId: owner.businessId });
if (!result.success) return { success: false, error: "Couldn't reactivate this staff member." };
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_REACTIVATED",
entityType: "StaffProfile",
entityId: staffId,
});
return { success: true };
}
export interface SetEligibilityResult {
success: boolean;
error?: string;
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string }[];
}
export async function setStaffServiceEligibilityAction(
staffId: string,
serviceIds: string[],
acknowledgeConflicts?: boolean,
): Promise<SetEligibilityResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const result = await setStaffServiceEligibility(prisma, { staffId, businessId: owner.businessId, serviceIds, acknowledgeConflicts });
if (!result.success) {
return {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't update this staff member's services.",
conflicts: result.conflicts.map((c) => ({
id: c.id,
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
serviceNames: c.services.map((s) => s.service.name).join(" + "),
startAt: c.startAt.toISOString(),
})),
};
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_SERVICES_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
metadata: { serviceIds },
});
return { success: true };
}
export interface WorkingHoursActionResult {
success: boolean;
error?: string;
}
/** Owner-on-behalf-of-staff working hours — same StaffService.setWorkingHours call the barber's own Phase 5 editor uses, just targeting a chosen staffId instead of the caller. No conflict detection here, matching the existing self-service editor's behaviour (a documented gap — see docs/phase-6-summary.md). */
export async function updateStaffWorkingHoursAction(
staffId: string,
days: WorkingHoursDayInput[],
): Promise<WorkingHoursActionResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
if (!target) return { success: false, error: "Staff member not found." };
const result = await setWorkingHours(prisma, { staffId, days });
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save working hours." };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_WORKING_HOURS_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
return { success: true };
}
export interface RegenerateInvitationResult {
success: boolean;
error?: string;
inviteToken?: string;
}
export async function regenerateInvitationAction(staffId: string): Promise<RegenerateInvitationResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
const invitation = await regenerateInvitation(prisma, { staffId, businessId: owner.businessId });
if (!invitation) return { success: false, error: "No invitation found for this staff member." };
return { success: true, inviteToken: invitation.token };
}
+25
View File
@@ -0,0 +1,25 @@
import type { Permission } from "@/generated/prisma/enums";
import { hasPermission } from "@/lib/auth/permissions";
/**
* Per-action owner permission checks — the mirror of
* src/features/staff/authorization.ts and src/features/booking/authorization.ts.
* The `(owner)` route group layout already gates on MANAGE_STAFF as a
* coarse "is this person any kind of owner" shell check, but that is not
* enough on its own (see docs/phase-6-summary.md, "Permission and
* business-isolation rules") — every owner Server Action re-derives the
* session and checks the *specific* permission that action needs, since a
* future non-Owner role could plausibly hold some but not all of these.
*/
export class OwnerPermissionDeniedError extends Error {
constructor(message = "You don't have permission to do that.") {
super(message);
this.name = "OwnerPermissionDeniedError";
}
}
export async function assertOwnerPermission(userId: string, permission: Permission): Promise<void> {
const allowed = await hasPermission(userId, permission);
if (!allowed) throw new OwnerPermissionDeniedError();
}
@@ -0,0 +1,117 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { addStaffAction } from "../actions/staff";
export interface AddStaffFormProps {
roles: { id: string; name: string }[];
services: { id: string; name: string }[];
}
export function AddStaffForm({ roles, services }: AddStaffFormProps) {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [image, setImage] = useState("");
const [bio, setBio] = useState("");
const [roleId, setRoleId] = useState(roles[0]?.id ?? "");
const [serviceIds, setServiceIds] = useState<string[]>([]);
const [isBookableOnline, setIsBookableOnline] = useState(true);
const [error, setError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isPending, startTransition] = useTransition();
const toggleService = (id: string) => {
setServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
};
const submit = () => {
setError(null);
startTransition(async () => {
const result = await addStaffAction({ name, email, phone: phone || undefined, image: image || undefined, bio: bio || undefined, roleId, serviceIds, isBookableOnline });
if (!result.success) {
setError(result.error ?? "Couldn't add this staff member.");
return;
}
if (result.inviteToken) {
setInviteLink(`${window.location.origin}/invite/accept?token=${result.inviteToken}`);
} else {
router.push(`/owner/team/${result.staffId}`);
}
});
};
const copyLink = async () => {
if (!inviteLink) return;
await navigator.clipboard.writeText(inviteLink);
setCopied(true);
};
if (inviteLink) {
return (
<div className="space-y-4 rounded-jumbo border-line border bg-white p-6">
<p className="text-sm font-bold">{name} has been added.</p>
<p className="text-muted text-sm">
Email delivery isn&apos;t wired up yet — copy this invite link and send it to them yourself. It lets them
set their own password. Expires in 7 days.
</p>
<div className="border-line bg-stone break-all rounded-lg border p-3 text-xs">{inviteLink}</div>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={copyLink}>
{copied ? "Copied" : "Copy Link"}
</Button>
<Button className="flex-1" onClick={() => router.push("/owner/team")}>
Done
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} />
<Input label="Email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
<Input label="Phone (optional)" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
<Input label="Photo URL (optional)" value={image} onChange={(e) => setImage(e.target.value)} />
<Textarea label="Bio (optional)" value={bio} onChange={(e) => setBio(e.target.value)} />
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Select>
<Switch label="Visible for online booking" checked={isBookableOnline} onCheckedChange={setIsBookableOnline} />
</div>
<div className="rounded-jumbo border-line border bg-white p-6">
<p className="mb-3 text-xs font-bold tracking-widest uppercase">Services Offered</p>
<div className="space-y-3">
{services.map((s) => (
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
))}
</div>
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={submit}>
Add Staff Member
</Button>
</div>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { updateBookingRuleAction } from "../actions/booking-rules";
import type { BookingRuleInput } from "@/services/scheduling/booking-rule.service";
export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
const router = useRouter();
const [form, setForm] = useState(initial);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const set = <K extends keyof BookingRuleInput>(key: K, value: BookingRuleInput[K]) => {
setSaved(false);
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSave = () => {
setError(null);
startTransition(async () => {
const result = await updateBookingRuleAction(form);
if (!result.success) {
setError(result.error ?? "Couldn't save booking rules.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Booking Window</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input
label="Minimum notice (minutes)"
hint="How soon before a slot a booking can be made."
type="number"
min={0}
value={form.minNoticeMinutes}
onChange={(e) => set("minNoticeMinutes", Number(e.target.value))}
/>
<Input
label="Maximum advance booking (days)"
hint="How far ahead customers can book."
type="number"
min={1}
value={form.maxAdvanceDays}
onChange={(e) => set("maxAdvanceDays", Number(e.target.value))}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Cancellation &amp; Rescheduling</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input
label="Free cancellation window (hours)"
hint="Cancelling later than this may count as a late cancellation."
type="number"
min={0}
value={form.cancellationWindowHours}
onChange={(e) => set("cancellationWindowHours", Number(e.target.value))}
/>
<Input
label="Free reschedule window (hours)"
type="number"
min={0}
value={form.rescheduleWindowHours}
onChange={(e) => set("rescheduleWindowHours", Number(e.target.value))}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Scheduling</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Select
label="Appointment slot interval"
value={String(form.slotIntervalMinutes)}
onChange={(e) => set("slotIntervalMinutes", Number(e.target.value))}
>
{[15, 30, 45, 60].map((m) => (
<option key={m} value={m}>
{m} min
</option>
))}
</Select>
<div className="grid grid-cols-2 gap-4">
<Input
label="Buffer before (min)"
type="number"
min={0}
value={form.bufferBeforeMinutes}
onChange={(e) => set("bufferBeforeMinutes", Number(e.target.value))}
/>
<Input
label="Buffer after (min)"
type="number"
min={0}
value={form.bufferAfterMinutes}
onChange={(e) => set("bufferAfterMinutes", Number(e.target.value))}
/>
</div>
<Input
label="Max appointments per day (blank = no limit)"
type="number"
min={1}
value={form.maxAppointmentsPerDay ?? ""}
onChange={(e) => set("maxAppointmentsPerDay", e.target.value ? Number(e.target.value) : null)}
/>
<Input
label="Max appointments per staff member per day (blank = no limit)"
type="number"
min={1}
value={form.maxAppointmentsPerStaffPerDay ?? ""}
onChange={(e) => set("maxAppointmentsPerStaffPerDay", e.target.value ? Number(e.target.value) : null)}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Customer Options</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<Switch
label="Customers can choose a barber"
description='If off, "Anyone" is assigned automatically.'
checked={form.customerCanChooseStaff}
onCheckedChange={(v) => set("customerCanChooseStaff", v)}
/>
<Switch
label="Allow guest bookings"
description="Let customers book without an account."
checked={form.allowGuestBooking}
onCheckedChange={(v) => set("allowGuestBooking", v)}
/>
<Switch
label="Require approval for new bookings"
description="Bookings wait for staff confirmation before they're CONFIRMED."
checked={form.requireApproval}
onCheckedChange={(v) => set("requireApproval", v)}
/>
<Switch label="Require deposit" description="Coming soon — not enforced yet." checked={false} disabled />
</div>
</section>
<div className="space-y-4 lg:col-span-2">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
Saved — the customer booking flow uses these rules immediately. Existing appointments are unaffected.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
</div>
);
}
@@ -0,0 +1,206 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { updateBusinessProfileAction, updateBusinessSettingsAction } from "../actions/business";
const TIMEZONES = ["Europe/London", "Europe/Paris", "Europe/Dublin", "America/New_York", "America/Los_Angeles"];
const CURRENCIES = ["GBP", "USD", "EUR"];
const LOCALES = ["en-GB", "en-US"];
export interface BusinessDetailsInitial {
name: string;
description: string;
logoUrl: string;
phone: string;
email: string;
website: string;
instagramUrl: string;
facebookUrl: string;
timezone: string;
currency: string;
locale: string;
addressLine1: string;
addressLine2: string;
city: string;
region: string;
postalCode: string;
country: string;
cancellationPolicy: string;
lateArrivalPolicy: string;
bookingTerms: string;
privacyPolicyUrl: string;
}
export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsInitial }) {
const router = useRouter();
const [form, setForm] = useState(initial);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const set = <K extends keyof BusinessDetailsInitial>(key: K, value: BusinessDetailsInitial[K]) => {
setSaved(false);
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSave = () => {
setError(null);
startTransition(async () => {
const profileResult = await updateBusinessProfileAction({
profile: {
name: form.name,
description: form.description || undefined,
logoUrl: form.logoUrl || undefined,
phone: form.phone || undefined,
email: form.email || undefined,
website: form.website || undefined,
instagramUrl: form.instagramUrl || undefined,
facebookUrl: form.facebookUrl || undefined,
timezone: form.timezone,
currency: form.currency,
locale: form.locale,
},
location: {
addressLine1: form.addressLine1,
addressLine2: form.addressLine2 || undefined,
city: form.city,
region: form.region || undefined,
postalCode: form.postalCode,
country: form.country,
},
});
if (!profileResult.success) {
setError(profileResult.error ?? "Couldn't save business details.");
return;
}
const settingsResult = await updateBusinessSettingsAction({
cancellationPolicy: form.cancellationPolicy || undefined,
lateArrivalPolicy: form.lateArrivalPolicy || undefined,
bookingTerms: form.bookingTerms || undefined,
privacyPolicyUrl: form.privacyPolicyUrl || undefined,
});
if (!settingsResult.success) {
setError(settingsResult.error ?? "Couldn't save business policies.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Profile</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Business Name" required value={form.name} onChange={(e) => set("name", e.target.value)} />
<Input label="Logo URL" placeholder="https://…" value={form.logoUrl} onChange={(e) => set("logoUrl", e.target.value)} />
<Textarea label="Description" value={form.description} onChange={(e) => set("description", e.target.value)} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Contact &amp; Address</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Street Address" required value={form.addressLine1} onChange={(e) => set("addressLine1", e.target.value)} />
<Input label="Address Line 2 (optional)" value={form.addressLine2} onChange={(e) => set("addressLine2", e.target.value)} />
<div className="grid grid-cols-2 gap-4">
<Input label="City" required value={form.city} onChange={(e) => set("city", e.target.value)} />
<Input label="Postcode" required value={form.postalCode} onChange={(e) => set("postalCode", e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input label="Region (optional)" value={form.region} onChange={(e) => set("region", e.target.value)} />
<Input label="Country" value={form.country} onChange={(e) => set("country", e.target.value)} />
</div>
<Input label="Phone" type="tel" value={form.phone} onChange={(e) => set("phone", e.target.value)} />
<Input label="Email" type="email" value={form.email} onChange={(e) => set("email", e.target.value)} />
<Input label="Website" type="url" placeholder="https://" value={form.website} onChange={(e) => set("website", e.target.value)} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Social Links</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Instagram" placeholder="@yourshop" value={form.instagramUrl} onChange={(e) => set("instagramUrl", e.target.value)} />
<Input label="Facebook" placeholder="facebook.com/yourshop" value={form.facebookUrl} onChange={(e) => set("facebookUrl", e.target.value)} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Regional Settings</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Select label="Time Zone" value={form.timezone} onChange={(e) => set("timezone", e.target.value)}>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</Select>
<div className="grid grid-cols-2 gap-4">
<Select label="Currency" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</Select>
<Select label="Locale" value={form.locale} onChange={(e) => set("locale", e.target.value)}>
{LOCALES.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</Select>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Policies</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Textarea
label="Cancellation Policy"
value={form.cancellationPolicy}
onChange={(e) => set("cancellationPolicy", e.target.value)}
/>
<Textarea
label="Late Arrival Policy"
value={form.lateArrivalPolicy}
onChange={(e) => set("lateArrivalPolicy", e.target.value)}
/>
<Textarea label="Booking Terms" value={form.bookingTerms} onChange={(e) => set("bookingTerms", e.target.value)} />
<Input
label="Privacy Policy URL"
type="url"
placeholder="https://"
value={form.privacyPolicyUrl}
onChange={(e) => set("privacyPolicyUrl", e.target.value)}
/>
</div>
</section>
<div className="space-y-4 lg:col-span-2">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
Saved — changes are live for customers immediately.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
</div>
);
}
@@ -0,0 +1,267 @@
"use client";
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
createCategoryAction,
deleteCategoryAction,
reorderCategoriesAction,
reorderServicesAction,
setCategoryActiveAction,
setServiceActiveAction,
updateCategoryAction,
} from "../actions/catalogue";
import { ServiceFormSheet } from "./service-form-sheet";
import type { EditableService } from "./service-form-sheet";
export interface CatalogueService {
id: string;
categoryId: string;
name: string;
description: string;
durationMinutes: number;
price: string;
bufferBeforeMinutes: number;
bufferAfterMinutes: number;
isActive: boolean;
allowOnlineBooking: boolean;
}
export interface CatalogueCategory {
id: string;
name: string;
isActive: boolean;
services: CatalogueService[];
}
function formatPrice(price: string): string {
return `£${Number(price).toFixed(2)}`;
}
export function CatalogueManager({ categories }: { categories: CatalogueCategory[] }) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [newCategoryName, setNewCategoryName] = useState("");
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
const [editingCategoryName, setEditingCategoryName] = useState("");
const [sheetOpen, setSheetOpen] = useState(false);
const [sheetCategoryId, setSheetCategoryId] = useState<string>(categories[0]?.id ?? "");
const [editingService, setEditingService] = useState<EditableService | null>(null);
const [isPending, startTransition] = useTransition();
const run = (fn: () => Promise<{ success: boolean; error?: string }>) => {
setError(null);
startTransition(async () => {
const result = await fn();
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
}
router.refresh();
});
};
const addCategory = () => {
if (!newCategoryName.trim()) return;
run(async () => {
const result = await createCategoryAction({ name: newCategoryName.trim() });
if (result.success) setNewCategoryName("");
return result;
});
};
const saveCategoryName = (id: string) => {
if (!editingCategoryName.trim()) return;
run(async () => {
const result = await updateCategoryAction(id, { name: editingCategoryName.trim() });
if (result.success) setEditingCategoryId(null);
return result;
});
};
const moveCategory = (index: number, direction: -1 | 1) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= categories.length) return;
const reordered = [...categories];
const [moved] = reordered.splice(index, 1);
if (!moved) return;
reordered.splice(targetIndex, 0, moved);
run(() => reorderCategoriesAction(reordered.map((c) => c.id)));
};
const moveService = (category: CatalogueCategory, index: number, direction: -1 | 1) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= category.services.length) return;
const reordered = [...category.services];
const [moved] = reordered.splice(index, 1);
if (!moved) return;
reordered.splice(targetIndex, 0, moved);
run(() => reorderServicesAction(category.id, reordered.map((s) => s.id)));
};
const openNewService = (categoryId: string) => {
setEditingService(null);
setSheetCategoryId(categoryId);
setSheetOpen(true);
};
const openEditService = (service: CatalogueService) => {
setEditingService({
id: service.id,
categoryId: service.categoryId,
name: service.name,
description: service.description,
durationMinutes: service.durationMinutes,
price: service.price,
bufferBeforeMinutes: service.bufferBeforeMinutes,
bufferAfterMinutes: service.bufferAfterMinutes,
allowOnlineBooking: service.allowOnlineBooking,
});
setSheetCategoryId(service.categoryId);
setSheetOpen(true);
};
return (
<div className="space-y-8">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{categories.map((category, categoryIndex) => (
<section key={category.id}>
<div className="mb-3 flex items-center justify-between px-1">
{editingCategoryId === category.id ? (
<div className="flex flex-1 items-center gap-2">
<Input value={editingCategoryName} onChange={(e) => setEditingCategoryName(e.target.value)} />
<Button size="sm" isLoading={isPending} onClick={() => saveCategoryName(category.id)}>
Save
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<h3 className={category.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
{category.name}
</h3>
<button
type="button"
onClick={() => {
setEditingCategoryId(category.id);
setEditingCategoryName(category.name);
}}
className="text-muted"
aria-label={`Rename ${category.name}`}
>
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
)}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => moveCategory(categoryIndex, -1)}
disabled={categoryIndex === 0}
aria-label={`Move ${category.name} up`}
className="text-muted disabled:opacity-30"
>
<ChevronUp className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => moveCategory(categoryIndex, 1)}
disabled={categoryIndex === categories.length - 1}
aria-label={`Move ${category.name} down`}
className="text-muted disabled:opacity-30"
>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
<Switch
checked={category.isActive}
onCheckedChange={(checked) => run(() => setCategoryActiveAction(category.id, checked))}
aria-label={`${category.name} active`}
/>
{category.services.length === 0 && (
<button
type="button"
onClick={() => run(() => deleteCategoryAction(category.id))}
className="text-danger"
aria-label={`Delete ${category.name}`}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="rounded-jumbo border-line divide-line divide-y border bg-white">
{category.services.map((service, serviceIndex) => (
<div key={service.id} className="flex items-center gap-3 px-5 py-4">
<div className="flex flex-col">
<button
type="button"
onClick={() => moveService(category, serviceIndex, -1)}
disabled={serviceIndex === 0}
aria-label={`Move ${service.name} up`}
className="text-muted disabled:opacity-30"
>
<ChevronUp className="h-3 w-3" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => moveService(category, serviceIndex, 1)}
disabled={serviceIndex === category.services.length - 1}
aria-label={`Move ${service.name} down`}
className="text-muted disabled:opacity-30"
>
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</button>
</div>
<button type="button" onClick={() => openEditService(service)} className="min-w-0 flex-1 text-left">
<p className={service.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
{service.name}
</p>
<p className="text-muted mt-0.5 text-xs">
{service.durationMinutes} min · {formatPrice(service.price)}
{!service.allowOnlineBooking && " · Not bookable online"}
</p>
</button>
<Switch
checked={service.isActive}
onCheckedChange={(checked) => run(() => setServiceActiveAction(service.id, checked))}
aria-label={`${service.name} active`}
/>
</div>
))}
<button
type="button"
onClick={() => openNewService(category.id)}
className="text-accent flex w-full items-center gap-3 px-5 py-4 text-xs font-bold tracking-widest uppercase"
>
<Plus className="h-4 w-4" aria-hidden="true" /> Add Service
</button>
</div>
</section>
))}
<div className="flex items-center gap-2">
<Input placeholder="New category name" value={newCategoryName} onChange={(e) => setNewCategoryName(e.target.value)} />
<Button isLoading={isPending} onClick={addCategory}>
Add Category
</Button>
</div>
<ServiceFormSheet
key={editingService?.id ?? "new"}
open={sheetOpen}
onClose={() => setSheetOpen(false)}
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
defaultCategoryId={sheetCategoryId}
existing={editingService}
/>
</div>
);
}
@@ -0,0 +1,216 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { updateLoyaltyConfigAction } from "../actions/loyalty-config";
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
const REWARD_TYPES: { value: ProgrammeConfigInput["rewardType"]; label: string }[] = [
{ value: "PERCENTAGE", label: "Percentage Discount" },
{ value: "FIXED_AMOUNT", label: "Fixed-Value Discount" },
{ value: "FREE_SERVICE", label: "Free Service" },
];
export interface LoyaltyConfigFormProps {
initialConfig: ProgrammeConfigInput;
initialEligibleServiceIds: string[];
initialEligibleStaffIds: string[];
services: { id: string; name: string }[];
staff: { id: string; name: string }[];
}
export function LoyaltyConfigForm({
initialConfig,
initialEligibleServiceIds,
initialEligibleStaffIds,
services,
staff,
}: LoyaltyConfigFormProps) {
const router = useRouter();
const [config, setConfig] = useState(initialConfig);
const [eligibleServiceIds, setEligibleServiceIds] = useState(initialEligibleServiceIds);
const [eligibleStaffIds, setEligibleStaffIds] = useState(initialEligibleStaffIds);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const set = <K extends keyof ProgrammeConfigInput>(key: K, value: ProgrammeConfigInput[K]) => {
setSaved(false);
setConfig((prev) => ({ ...prev, [key]: value }));
};
const toggleInList = (list: string[], setList: (v: string[]) => void, id: string) => {
setSaved(false);
setList(list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);
};
const handleSave = () => {
setError(null);
startTransition(async () => {
const result = await updateLoyaltyConfigAction({ config, eligibleServiceIds, eligibleStaffIds });
if (!result.success) {
setError(result.error ?? "Couldn't save loyalty settings.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="rounded-jumbo border-line border bg-white p-6 lg:col-span-2">
<Switch
label="Loyalty programme enabled"
description="Turn off to stop earning/redeeming everywhere, without losing the configuration below."
checked={config.isEnabled}
onCheckedChange={(v) => set("isEnabled", v)}
/>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Reward Rules</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<Input
label="Stamps required for reward"
type="number"
min={1}
value={config.stampsRequired}
onChange={(e) => set("stampsRequired", Number(e.target.value))}
/>
<Select label="Reward Type" value={config.rewardType} onChange={(e) => set("rewardType", e.target.value as ProgrammeConfigInput["rewardType"])}>
{REWARD_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
{config.rewardType === "FREE_SERVICE" ? (
<Select
label="Free Service"
value={config.freeServiceId ?? ""}
onChange={(e) => set("freeServiceId", e.target.value)}
>
<option value="">Choose a service…</option>
{services.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
) : (
<Input
label={config.rewardType === "PERCENTAGE" ? "Percentage off (1–100)" : "Fixed amount off"}
value={config.rewardValue ?? ""}
onChange={(e) => set("rewardValue", e.target.value)}
placeholder={config.rewardType === "PERCENTAGE" ? "50" : "10.00"}
/>
)}
<Input label="Reward Name" value={config.rewardLabel ?? ""} onChange={(e) => set("rewardLabel", e.target.value)} placeholder="e.g. 50% Off Any Service" />
<Input
label="Maximum reward value (optional)"
value={config.maxRewardValue ?? ""}
onChange={(e) => set("maxRewardValue", e.target.value)}
hint="Caps a percentage discount so it never exceeds this amount."
/>
</div>
</section>
<section className="space-y-3 lg:col-span-2">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Eligibility</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-2 lg:gap-6 lg:space-y-0">
<div>
<p className="mb-2 text-xs font-bold">Eligible services (none checked = all services earn stamps)</p>
<div className="space-y-2">
{services.map((s) => (
<Checkbox
key={s.id}
label={s.name}
checked={eligibleServiceIds.includes(s.id)}
onChange={() => toggleInList(eligibleServiceIds, setEligibleServiceIds, s.id)}
/>
))}
</div>
</div>
<div className="border-line border-t pt-4 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
<p className="mb-2 text-xs font-bold">Eligible staff (none checked = all staff earn stamps)</p>
<div className="space-y-2">
{staff.map((s) => (
<Checkbox
key={s.id}
label={s.name}
checked={eligibleStaffIds.includes(s.id)}
onChange={() => toggleInList(eligibleStaffIds, setEligibleStaffIds, s.id)}
/>
))}
</div>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules &amp; Expiry</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<Switch label="Apply reward automatically" checked={config.autoApplyReward} onCheckedChange={(v) => set("autoApplyReward", v)} />
<Switch
label="Require staff approval to redeem"
description="Redemption already requires a permitted staff member — this is informational only this phase."
checked={config.requiresStaffApproval}
onCheckedChange={(v) => set("requiresStaffApproval", v)}
/>
<Switch label="Earn stamps on cancelled appointments" checked={config.earnOnCancelled} onCheckedChange={(v) => set("earnOnCancelled", v)} />
<Switch label="Earn stamps on no-shows" checked={config.earnOnNoShow} onCheckedChange={(v) => set("earnOnNoShow", v)} />
<div className="grid grid-cols-2 gap-4">
<Input
label="Stamps expire after (days, optional)"
type="number"
min={1}
value={config.stampsExpireAfterDays ?? ""}
onChange={(e) => set("stampsExpireAfterDays", e.target.value ? Number(e.target.value) : undefined)}
/>
<Input
label="Reward expires after (days, optional)"
type="number"
min={1}
value={config.rewardExpiresAfterDays ?? ""}
onChange={(e) => set("rewardExpiresAfterDays", e.target.value ? Number(e.target.value) : undefined)}
/>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Card Appearance</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Card background color" value={config.cardBackgroundColor ?? ""} onChange={(e) => set("cardBackgroundColor", e.target.value)} placeholder="#111b15" />
<Input label="Card background image URL (optional)" value={config.cardBackgroundImageUrl ?? ""} onChange={(e) => set("cardBackgroundImageUrl", e.target.value)} />
<Input label="Stamp icon" value={config.stampIcon ?? ""} onChange={(e) => set("stampIcon", e.target.value)} placeholder="scissors" />
<Input label="Terms line" value={config.termsLine ?? ""} onChange={(e) => set("termsLine", e.target.value)} placeholder="Valid for 12 months. One reward per visit." />
</div>
</section>
<div className="space-y-4 lg:col-span-2">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
</div>
);
}
@@ -0,0 +1,201 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Weekday } from "@/generated/prisma/enums";
import { updateOpeningHoursAction } from "../actions/business";
import type { OpeningHoursActionResult } from "../actions/business";
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
const DAY_LABEL: Record<Weekday, string> = {
MONDAY: "Monday",
TUESDAY: "Tuesday",
WEDNESDAY: "Wednesday",
THURSDAY: "Thursday",
FRIDAY: "Friday",
SATURDAY: "Saturday",
SUNDAY: "Sunday",
};
function toTimeString(minutes: number | null): string {
if (minutes === null) return "09:00";
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function toMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return (h ?? 0) * 60 + (m ?? 0);
}
interface DayState {
dayOfWeek: Weekday;
isClosed: boolean;
startTime: string;
endTime: string;
}
export interface InitialOpeningHoursRow {
dayOfWeek: Weekday;
isClosed: boolean;
opensAtMinutes: number | null;
closesAtMinutes: number | null;
}
export function OpeningHoursForm({ initialRows }: { initialRows: InitialOpeningHoursRow[] }) {
const router = useRouter();
const [days, setDays] = useState<DayState[]>(() =>
DAY_ORDER.map((dayOfWeek) => {
const existing = initialRows.find((r) => r.dayOfWeek === dayOfWeek);
return {
dayOfWeek,
isClosed: existing?.isClosed ?? true,
startTime: toTimeString(existing?.opensAtMinutes ?? null),
endTime: toTimeString(existing?.closesAtMinutes ?? 19 * 60),
};
}),
);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [conflicts, setConflicts] = useState<NonNullable<OpeningHoursActionResult["conflicts"]>>([]);
const [isPending, startTransition] = useTransition();
const updateDay = (dayOfWeek: Weekday, patch: Partial<DayState>) => {
setSaved(false);
setConflicts([]);
setDays((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
};
const copyMondayToAll = () => {
const monday = days.find((d) => d.dayOfWeek === Weekday.MONDAY);
if (!monday) return;
setSaved(false);
setConflicts([]);
setDays((prev) =>
prev.map((d) => (d.dayOfWeek === Weekday.MONDAY ? d : { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime })),
);
};
const submit = (acknowledgeConflicts: boolean) => {
setError(null);
startTransition(async () => {
const result = await updateOpeningHoursAction(
days.map((d) => ({
dayOfWeek: d.dayOfWeek,
isClosed: d.isClosed,
...(d.isClosed ? {} : { opensAtMinutes: toMinutes(d.startTime), closesAtMinutes: toMinutes(d.endTime) }),
})),
acknowledgeConflicts,
);
if (!result.success) {
setError(result.error ?? "Couldn't save opening hours.");
setConflicts(result.conflicts ?? []);
return;
}
setSaved(true);
setConflicts([]);
router.refresh();
});
};
if (conflicts.length > 0) {
return (
<div className="space-y-4">
<p role="alert" className="text-danger text-sm font-medium">
These hours would leave {conflicts.length} upcoming appointment{conflicts.length > 1 ? "s" : ""} outside
the business&apos;s open hours. They won&apos;t be cancelled automatically — review them, then confirm if
you still want to save.
</p>
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">
{c.serviceNames} · with {c.staffName}
</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => setConflicts([])}>
Go Back
</Button>
<Button variant="destructive" className="flex-1" isLoading={isPending} onClick={() => submit(true)}>
Save Anyway
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<button type="button" onClick={copyMondayToAll} className="text-accent text-xs font-bold tracking-widest uppercase underline">
Copy Monday to all days
</button>
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
{days.map((day) => (
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-center gap-3">
<Switch
checked={!day.isClosed}
onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isClosed: !checked })}
aria-label={`${DAY_LABEL[day.dayOfWeek]} open`}
/>
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
</div>
{day.isClosed ? (
<span className="text-muted text-xs">Closed</span>
) : (
<div className="flex items-center gap-2">
<input
type="time"
value={day.startTime}
onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })}
className="border-line rounded-lg border px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} opening time`}
/>
<span className="text-muted text-xs">to</span>
<input
type="time"
value={day.endTime}
onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })}
className="border-line rounded-lg border px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} closing time`}
/>
</div>
)}
</div>
))}
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
Saved. The booking engine reflects these hours immediately.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
Save
</Button>
</div>
);
}
@@ -0,0 +1,127 @@
"use client";
import { CalendarOff } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import type { OwnerBlockTimeConflict } from "../actions/calendar";
import { ownerBlockTimeAction } from "../actions/calendar";
function toMinutes(hhmm: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
/** Owner variant of the staff BlockTimeButton (src/features/staff/components/block-time-button.tsx) — same UI, targets a chosen staff member instead of only the signed-in user. */
export function OwnerBlockTimeButton({ staffId, staffName, date }: { staffId: string; staffName: string; date: string }) {
const [open, setOpen] = useState(false);
const [startTime, setStartTime] = useState("12:00");
const [endTime, setEndTime] = useState("13:00");
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<OwnerBlockTimeConflict[] | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleClose = () => {
setOpen(false);
setError(null);
setConflicts(null);
setReason("");
};
const submit = (acknowledgeConflicts: boolean) => {
const startMinutes = toMinutes(startTime);
const endMinutes = toMinutes(endTime);
if (startMinutes === null || endMinutes === null || startMinutes >= endMinutes) {
setError("Start time must be before end time.");
return;
}
setError(null);
startTransition(async () => {
const result = await ownerBlockTimeAction({ staffId, date, startMinutes, endMinutes, reason, acknowledgeConflicts });
if (!result.success) {
setError(result.error ?? "Something went wrong.");
setConflicts(result.conflicts ?? null);
return;
}
setOpen(false);
setConflicts(null);
router.refresh();
});
};
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
<CalendarOff className="h-4 w-4" aria-hidden="true" />
Block Time
</Button>
<BottomSheet open={open} onClose={handleClose} title={`Block Time — ${staffName}`}>
<div className="space-y-4 pb-2">
<p className="text-muted text-sm">
Reserve part of{" "}
{new Date(`${date}T00:00:00`).toLocaleDateString("en-GB", {
weekday: "long",
day: "numeric",
month: "short",
})}{" "}
on {staffName}&apos;s schedule for something other than appointments.
</p>
{conflicts && conflicts.length > 0 ? (
<div className="space-y-3">
<p role="alert" className="text-danger text-sm font-medium">
This overlaps {conflicts.length} existing appointment
{conflicts.length > 1 ? "s" : ""}:
</p>
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={handleClose}>
Cancel
</Button>
<Button variant="destructive" className="flex-1" isLoading={isPending} onClick={() => submit(true)}>
Block Anyway
</Button>
</div>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<Input label="Start" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
<Input label="End" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
</div>
<Input
label="Reason (optional)"
placeholder="e.g. Team meeting"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
Block This Time
</Button>
</>
)}
</div>
</BottomSheet>
</>
);
}
@@ -0,0 +1,94 @@
"use client";
import { Gift } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { redeemRewardAction } from "../actions/loyalty";
/**
* Available to any staff member with MANAGE_LOYALTY_STAMPS (Owner and
* Barber both hold it in the seed — see redeemRewardAction) — surfaced on
* both the owner and staff appointment details pages, since redemption is
* an operational action, not owner-only configuration. See
* docs/phase-6-summary.md, "Loyalty configuration and redemption approach".
*/
export function RedeemRewardButton({
appointmentId,
rewardId,
rewardLabel,
subtotal,
}: {
appointmentId: string;
rewardId: string;
rewardLabel: string;
subtotal: number;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ discountAmount: string } | null>(null);
const [isPending, startTransition] = useTransition();
const close = () => {
const shouldRefresh = !!result;
setOpen(false);
setError(null);
setResult(null);
if (shouldRefresh) router.refresh();
};
const submit = () => {
setError(null);
startTransition(async () => {
const res = await redeemRewardAction({ rewardId, appointmentId });
if (!res.success) {
setError(res.error ?? "Couldn't redeem this reward.");
return;
}
setResult({ discountAmount: res.discountAmount ?? "0.00" });
});
};
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
<Gift className="text-accent-loyalty h-4 w-4" aria-hidden="true" />
Redeem Reward
</Button>
<Modal open={open} onClose={close} title={result ? "Reward Redeemed" : "Redeem Reward"}>
{result ? (
<div className="space-y-4">
<p role="status" className="text-muted text-sm">
{rewardLabel} has been applied to this appointment — £{result.discountAmount} off.
</p>
<Button className="w-full" onClick={close}>
Done
</Button>
</div>
) : (
<div className="space-y-4">
<p className="text-muted text-sm">
Apply <span className="text-ink font-bold">{rewardLabel}</span> to this appointment
(subtotal £{subtotal.toFixed(2)})? This can&apos;t be undone.
</p>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={close}>
Cancel
</Button>
<Button className="flex-1" isLoading={isPending} onClick={submit}>
Apply Reward
</Button>
</div>
</div>
)}
</Modal>
</>
);
}
@@ -0,0 +1,112 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { createServiceAction, updateServiceAction } from "../actions/catalogue";
export interface EditableService {
id: string;
categoryId: string;
name: string;
description: string;
durationMinutes: number;
price: string;
bufferBeforeMinutes: number;
bufferAfterMinutes: number;
allowOnlineBooking: boolean;
}
export function ServiceFormSheet({
open,
onClose,
categories,
defaultCategoryId,
existing,
}: {
open: boolean;
onClose: () => void;
categories: { id: string; name: string }[];
defaultCategoryId: string;
existing?: EditableService | null;
}) {
const router = useRouter();
const [categoryId, setCategoryId] = useState(existing?.categoryId ?? defaultCategoryId);
const [name, setName] = useState(existing?.name ?? "");
const [description, setDescription] = useState(existing?.description ?? "");
const [duration, setDuration] = useState(String(existing?.durationMinutes ?? 30));
const [price, setPrice] = useState(existing?.price ?? "");
const [bufferBefore, setBufferBefore] = useState(String(existing?.bufferBeforeMinutes ?? 0));
const [bufferAfter, setBufferAfter] = useState(String(existing?.bufferAfterMinutes ?? 0));
const [allowOnlineBooking, setAllowOnlineBooking] = useState(existing?.allowOnlineBooking ?? true);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const submit = () => {
setError(null);
const durationMinutes = Number(duration);
const bufferBeforeMinutes = Number(bufferBefore);
const bufferAfterMinutes = Number(bufferAfter);
if (!Number.isInteger(durationMinutes) || durationMinutes <= 0) {
setError("Duration must be a positive number of minutes.");
return;
}
startTransition(async () => {
const input = {
categoryId,
name,
description: description || undefined,
durationMinutes,
price,
bufferBeforeMinutes,
bufferAfterMinutes,
allowOnlineBooking,
};
const result = existing ? await updateServiceAction(existing.id, input) : await createServiceAction(input);
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
}
onClose();
router.refresh();
});
};
return (
<BottomSheet open={open} onClose={onClose} title={existing ? "Edit Service" : "New Service"}>
<div className="space-y-4 pb-2">
<Select label="Category" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Textarea label="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<div className="grid grid-cols-2 gap-4">
<Input label="Duration (min)" type="number" min={1} value={duration} onChange={(e) => setDuration(e.target.value)} />
<Input label="Price" placeholder="22.50" value={price} onChange={(e) => setPrice(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input label="Buffer Before (min)" type="number" min={0} value={bufferBefore} onChange={(e) => setBufferBefore(e.target.value)} />
<Input label="Buffer After (min)" type="number" min={0} value={bufferAfter} onChange={(e) => setBufferAfter(e.target.value)} />
</div>
<Switch label="Available for online booking" checked={allowOnlineBooking} onCheckedChange={setAllowOnlineBooking} />
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={submit}>
{existing ? "Save Changes" : "Add Service"}
</Button>
</div>
</BottomSheet>
);
}
@@ -0,0 +1,282 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Select } from "@/components/ui/select";
import {
deactivateStaffAction,
reactivateStaffAction,
regenerateInvitationAction,
setStaffServiceEligibilityAction,
updateStaffAction,
} from "../actions/staff";
export interface StaffDetailPanelProps {
staffId: string;
currentRoleId: string;
roles: { id: string; name: string }[];
allServices: { id: string; name: string }[];
initialServiceIds: string[];
status: "ACTIVE" | "ON_BREAK" | "INACTIVE";
hasPendingInvitation: boolean;
}
type ConflictItem = { id: string; customerName: string; serviceNames: string; startAt: string };
export function StaffDetailPanel({
staffId,
currentRoleId,
roles,
allServices,
initialServiceIds,
status,
hasPendingInvitation,
}: StaffDetailPanelProps) {
const router = useRouter();
const [roleId, setRoleId] = useState(currentRoleId);
const [serviceIds, setServiceIds] = useState(initialServiceIds);
const [roleError, setRoleError] = useState<string | null>(null);
const [roleSaved, setRoleSaved] = useState(false);
const [servicesError, setServicesError] = useState<string | null>(null);
const [servicesSaved, setServicesSaved] = useState(false);
const [serviceConflicts, setServiceConflicts] = useState<ConflictItem[]>([]);
const [deactivateConflicts, setDeactivateConflicts] = useState<ConflictItem[] | null>(null);
const [deactivateError, setDeactivateError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isRolePending, startRoleTransition] = useTransition();
const [isServicesPending, startServicesTransition] = useTransition();
const [isDeactivatePending, startDeactivateTransition] = useTransition();
const [isInvitePending, startInviteTransition] = useTransition();
const saveRole = () => {
setRoleError(null);
setRoleSaved(false);
startRoleTransition(async () => {
const result = await updateStaffAction(staffId, { roleId });
if (!result.success) {
setRoleError(result.error ?? "Couldn't change role.");
return;
}
setRoleSaved(true);
router.refresh();
});
};
const toggleService = (id: string) => {
setServicesSaved(false);
setServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
};
const saveServices = (acknowledgeConflicts: boolean) => {
setServicesError(null);
startServicesTransition(async () => {
const result = await setStaffServiceEligibilityAction(staffId, serviceIds, acknowledgeConflicts);
if (!result.success) {
setServicesError(result.error ?? "Couldn't update services.");
setServiceConflicts(result.conflicts ?? []);
return;
}
setServicesSaved(true);
setServiceConflicts([]);
router.refresh();
});
};
const deactivate = (acknowledgeConflicts: boolean) => {
setDeactivateError(null);
startDeactivateTransition(async () => {
const result = await deactivateStaffAction(staffId, acknowledgeConflicts);
if (!result.success) {
setDeactivateError(result.error ?? "Couldn't deactivate this staff member.");
setDeactivateConflicts(result.conflicts ?? []);
return;
}
setDeactivateConflicts(null);
router.refresh();
});
};
const reactivate = () => {
startDeactivateTransition(async () => {
await reactivateStaffAction(staffId);
router.refresh();
});
};
const regenerateLink = () => {
startInviteTransition(async () => {
const result = await regenerateInvitationAction(staffId);
if (result.success && result.inviteToken) {
setInviteLink(`${window.location.origin}/invite/accept?token=${result.inviteToken}`);
}
});
};
const copyLink = async () => {
if (!inviteLink) return;
await navigator.clipboard.writeText(inviteLink);
setCopied(true);
};
return (
<div className="space-y-6">
{hasPendingInvitation && (
<section className="rounded-jumbo border-warning/30 bg-warning/10 border p-5">
<p className="text-warning-foreground text-sm font-bold">Invitation pending</p>
<p className="text-muted mt-1 text-xs">
This person hasn&apos;t set a password yet. Email delivery isn&apos;t wired up — send them the link
yourself.
</p>
{inviteLink ? (
<div className="mt-3 space-y-2">
<div className="border-line bg-white break-all rounded-lg border p-3 text-xs">{inviteLink}</div>
<Button size="sm" variant="secondary" onClick={copyLink}>
{copied ? "Copied" : "Copy Link"}
</Button>
</div>
) : (
<Button size="sm" variant="secondary" className="mt-3" isLoading={isInvitePending} onClick={regenerateLink}>
Get Invite Link
</Button>
)}
</section>
)}
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Role &amp; Permissions</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Select>
{roleError && (
<p role="alert" className="text-danger text-sm font-medium">
{roleError}
</p>
)}
{roleSaved && !roleError && (
<p role="status" className="text-accent text-sm font-medium">
Saved.
</p>
)}
<Button size="sm" isLoading={isRolePending} onClick={saveRole} disabled={roleId === currentRoleId}>
Save Role
</Button>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Services Provided</h3>
<div className="rounded-jumbo border-line space-y-3 border bg-white p-6">
{allServices.map((s) => (
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
))}
{serviceConflicts.length > 0 && (
<div className="border-line space-y-2 border-t pt-3">
<p role="alert" className="text-danger text-sm font-medium">
This staff member has upcoming appointments for a service you&apos;re removing:
</p>
<ul className="space-y-2">
{serviceConflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<Button size="sm" variant="destructive" isLoading={isServicesPending} onClick={() => saveServices(true)}>
Save Anyway
</Button>
</div>
)}
{servicesError && serviceConflicts.length === 0 && (
<p role="alert" className="text-danger text-sm font-medium">
{servicesError}
</p>
)}
{servicesSaved && !servicesError && (
<p role="status" className="text-accent text-sm font-medium">
Saved.
</p>
)}
{serviceConflicts.length === 0 && (
<Button size="sm" isLoading={isServicesPending} onClick={() => saveServices(false)}>
Save Services
</Button>
)}
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Availability</h3>
<div className="rounded-jumbo border-line overflow-hidden border bg-white">
<Link href={`/owner/team/${staffId}/hours`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
<span className="text-sm font-bold">Working Hours</span>
<span className="text-muted text-xs">Edit</span>
</Link>
</div>
</section>
<section className="pb-2">
{status === "INACTIVE" ? (
<Button variant="secondary" className="w-full" isLoading={isDeactivatePending} onClick={reactivate}>
Reactivate Staff Member
</Button>
) : deactivateConflicts && deactivateConflicts.length > 0 ? (
<div className="border-danger/30 bg-danger/5 space-y-3 rounded-jumbo border p-5">
<p role="alert" className="text-danger text-sm font-medium">
This staff member still has {deactivateConflicts.length} upcoming appointment
{deactivateConflicts.length > 1 ? "s" : ""}. Deactivating won&apos;t cancel them — they&apos;ll need to
be reassigned or handled separately.
</p>
<ul className="space-y-2">
{deactivateConflicts.map((c) => (
<li key={c.id} className="rounded-lg border border-white bg-white p-3 text-xs">
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => setDeactivateConflicts(null)}>
Go Back
</Button>
<Button variant="destructive" className="flex-1" isLoading={isDeactivatePending} onClick={() => deactivate(true)}>
Deactivate Anyway
</Button>
</div>
</div>
) : (
<>
{deactivateError && (
<p role="alert" className="text-danger mb-2 text-sm font-medium">
{deactivateError}
</p>
)}
<Button
variant="destructive"
className="w-full"
isLoading={isDeactivatePending}
onClick={() => deactivate(false)}
>
Deactivate Staff Member
</Button>
</>
)}
</section>
</div>
);
}
@@ -13,11 +13,31 @@ import {
markNoShowAction, markNoShowAction,
startAppointmentAction, startAppointmentAction,
} from "../actions/appointment-actions"; } from "../actions/appointment-actions";
import type { StaffAppointmentActionResult } from "../actions/appointment-actions";
type RawStatus = type RawStatus =
"PENDING" | "CONFIRMED" | "CHECKED_IN" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED" | "NO_SHOW"; "PENDING" | "CONFIRMED" | "CHECKED_IN" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED" | "NO_SHOW";
type ConfirmKind = "complete" | "no_show" | "cancel"; type ConfirmKind = "complete" | "no_show" | "cancel";
/** Same shape the owner variant (src/features/owner/actions/appointments.ts) implements — see AppointmentStatusActions' `actions` prop. */
export interface AppointmentActionSet {
confirm: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
checkIn: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
start: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
complete: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
markNoShow: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
cancel: (params: { appointmentId: string; reason?: string }) => Promise<StaffAppointmentActionResult>;
}
const DEFAULT_ACTIONS: AppointmentActionSet = {
confirm: confirmAppointmentAction,
checkIn: checkInAppointmentAction,
start: startAppointmentAction,
complete: completeAppointmentAction,
markNoShow: markNoShowAction,
cancel: cancelStaffAppointmentAction,
};
/** /**
* The status-appropriate action set — shared by Current Appointment and * The status-appropriate action set — shared by Current Appointment and
* Appointment Details, since both need exactly the same buttons for the * Appointment Details, since both need exactly the same buttons for the
@@ -25,14 +45,19 @@ type ConfirmKind = "complete" | "no_show" | "cancel";
* AppointmentService's own state machine; this component never decides * AppointmentService's own state machine; this component never decides
* whether a transition is legal, only which buttons to *offer* for a given * whether a transition is legal, only which buttons to *offer* for a given
* status (offering an illegal one would just get rejected server-side — * status (offering an illegal one would just get rejected server-side —
* see appointment-actions.ts). * see appointment-actions.ts). `actions` defaults to the staff (own-only)
* Server Actions; the owner appointment details page passes the owner
* variant instead, so this one component serves both contexts without
* forking — see docs/phase-6-summary.md, "Owner appointment access".
*/ */
export function AppointmentStatusActions({ export function AppointmentStatusActions({
appointmentId, appointmentId,
status, status,
actions = DEFAULT_ACTIONS,
}: { }: {
appointmentId: string; appointmentId: string;
status: RawStatus; status: RawStatus;
actions?: AppointmentActionSet;
}) { }) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -59,10 +84,10 @@ export function AppointmentStatusActions({
startTransition(async () => { startTransition(async () => {
const result = const result =
confirmKind === "complete" confirmKind === "complete"
? await completeAppointmentAction(appointmentId) ? await actions.complete(appointmentId)
: confirmKind === "no_show" : confirmKind === "no_show"
? await markNoShowAction(appointmentId) ? await actions.markNoShow(appointmentId)
: await cancelStaffAppointmentAction({ appointmentId, reason: reason || undefined }); : await actions.cancel({ appointmentId, reason: reason || undefined });
if (!result.success) { if (!result.success) {
setError(result.error ?? "Something went wrong."); setError(result.error ?? "Something went wrong.");
return; return;
@@ -101,7 +126,7 @@ export function AppointmentStatusActions({
<Button <Button
size="sm" size="sm"
isLoading={isPending} isLoading={isPending}
onClick={() => runQuiet(() => confirmAppointmentAction(appointmentId))} onClick={() => runQuiet(() => actions.confirm(appointmentId))}
> >
Confirm Booking Confirm Booking
</Button> </Button>
@@ -110,7 +135,7 @@ export function AppointmentStatusActions({
<Button <Button
size="sm" size="sm"
isLoading={isPending} isLoading={isPending}
onClick={() => runQuiet(() => checkInAppointmentAction(appointmentId))} onClick={() => runQuiet(() => actions.checkIn(appointmentId))}
> >
Check In Check In
</Button> </Button>
@@ -119,7 +144,7 @@ export function AppointmentStatusActions({
<Button <Button
size="sm" size="sm"
isLoading={isPending} isLoading={isPending}
onClick={() => runQuiet(() => startAppointmentAction(appointmentId))} onClick={() => runQuiet(() => actions.start(appointmentId))}
> >
Start Appointment Start Appointment
</Button> </Button>
+9 -1
View File
@@ -15,15 +15,23 @@ export function DateStrip({
businessTimezone, businessTimezone,
basePath, basePath,
view, view,
extraParams,
}: { }: {
selectedDate: CalendarDate; selectedDate: CalendarDate;
businessTimezone: string; businessTimezone: string;
basePath: string; basePath: string;
view?: string; view?: string;
/** Preserved across date navigation — e.g. the owner calendar's `?staffId=` filter. */
extraParams?: Record<string, string>;
}) { }) {
const today = todayIn(businessTimezone); const today = todayIn(businessTimezone);
const viewParam = view ? `&view=${view}` : ""; const viewParam = view ? `&view=${view}` : "";
const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}`; const extraParamsString = extraParams
? Object.entries(extraParams)
.map(([key, value]) => `&${key}=${encodeURIComponent(value)}`)
.join("")
: "";
const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}${extraParamsString}`;
const days = Array.from({ length: 7 }, (_, i) => addDays(selectedDate, i - 3)); const days = Array.from({ length: 7 }, (_, i) => addDays(selectedDate, i - 3));
const label = new Date(`${selectedDate}T00:00:00`).toLocaleDateString("en-GB", { const label = new Date(`${selectedDate}T00:00:00`).toLocaleDateString("en-GB", {
@@ -52,6 +52,7 @@ export function DayCalendar({
blocks, blocks,
workingWindow, workingWindow,
currentTimeMinutes, currentTimeMinutes,
appointmentHrefBase = "/staff/appointments",
}: { }: {
appointments: CalendarAppointment[]; appointments: CalendarAppointment[];
blocks: CalendarBlock[]; blocks: CalendarBlock[];
@@ -59,6 +60,8 @@ export function DayCalendar({
workingWindow: { startMinutes: number; endMinutes: number } | null; workingWindow: { startMinutes: number; endMinutes: number } | null;
/** Minutes-from-midnight right now, only passed when the viewed day is today. */ /** Minutes-from-midnight right now, only passed when the viewed day is today. */
currentTimeMinutes: number | null; currentTimeMinutes: number | null;
/** Owner's all-staff calendar reuses this component unchanged but links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar approach". */
appointmentHrefBase?: string;
}) { }) {
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i); const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR; const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
@@ -121,7 +124,7 @@ export function DayCalendar({
{appointments.map((appt) => ( {appointments.map((appt) => (
<Link <Link
key={appt.id} key={appt.id}
href={`/staff/appointments/${appt.id}`} href={`${appointmentHrefBase}/${appt.id}`}
className={cn( className={cn(
"absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 bg-white p-2 shadow-sm transition-shadow hover:shadow-md", "absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 bg-white p-2 shadow-sm transition-shadow hover:shadow-md",
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1", "focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
@@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Weekday } from "@/generated/prisma/enums"; import { Weekday } from "@/generated/prisma/enums";
import { saveWorkingHoursAction } from "../actions/working-hours"; import { saveWorkingHoursAction } from "../actions/working-hours";
import type { WorkingHoursActionResult } from "../actions/working-hours";
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
const DAY_ORDER: Weekday[] = [ const DAY_ORDER: Weekday[] = [
Weekday.MONDAY, Weekday.MONDAY,
@@ -52,7 +54,16 @@ export interface InitialWorkingHoursRow {
endMinutes: number | null; endMinutes: number | null;
} }
export function WorkingHoursForm({ initialRows, businessHoursLabel }: { initialRows: InitialWorkingHoursRow[]; businessHoursLabel: Record<Weekday, string> }) { export function WorkingHoursForm({
initialRows,
businessHoursLabel,
onSave = saveWorkingHoursAction,
}: {
initialRows: InitialWorkingHoursRow[];
businessHoursLabel: Record<Weekday, string>;
/** Owner-on-behalf-of-staff editing (src/app/(owner)/owner/team/[id]/hours) injects its own Server Action here instead of the self-service default — same pattern as AppointmentStatusActions' `actions` prop. */
onSave?: (days: WorkingHoursDayInput[]) => Promise<WorkingHoursActionResult>;
}) {
const router = useRouter(); const router = useRouter();
const [days, setDays] = useState<DayState[]>(() => const [days, setDays] = useState<DayState[]>(() =>
DAY_ORDER.map((dayOfWeek) => { DAY_ORDER.map((dayOfWeek) => {
@@ -84,7 +95,7 @@ export function WorkingHoursForm({ initialRows, businessHoursLabel }: { initialR
const handleSave = () => { const handleSave = () => {
setError(null); setError(null);
startTransition(async () => { startTransition(async () => {
const result = await saveWorkingHoursAction( const result = await onSave(
days.map((d) => ({ days.map((d) => ({
dayOfWeek: d.dayOfWeek, dayOfWeek: d.dayOfWeek,
isOff: d.isOff, isOff: d.isOff,
+30
View File
@@ -0,0 +1,30 @@
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
/**
* A thin, typed wrapper over the (previously unused, see schema.prisma)
* AuditLog table — used for owner actions that change shared business
* configuration (staff, roles, services, hours, booking rules, loyalty,
* reward redemption). Never stores passwords, invitation tokens, or other
* secrets in `metadata` — callers pass only display-safe before/after
* values.
*/
export async function writeAuditLog(
prisma: PrismaClient,
params: {
actorUserId: string | null;
action: string;
entityType: string;
entityId: string;
metadata?: Prisma.InputJsonValue;
},
): Promise<void> {
await prisma.auditLog.create({
data: {
actorUserId: params.actorUserId,
action: params.action,
entityType: params.entityType,
entityId: params.entityId,
metadata: params.metadata,
},
});
}
+16 -6
View File
@@ -1,14 +1,15 @@
import type { BottomNavItem } from "@/components/navigation/bottom-nav"; import type { BottomNavItem } from "@/components/navigation/bottom-nav";
/** /**
* Single source of truth for the staff/owner bottom nav. The templates had * Single source of truth for the staff bottom nav. The templates had two
* two competing 4-item sets for what is conceptually the same nav * competing 4-item sets for what is conceptually the same nav
* (Calendar/Current/Clients/Settings on most staff+owner screens vs. * (Calendar/Current/Clients/Settings on most staff+owner screens vs.
* Dashboard/Staff/Calendar/Admin on the Team Management screen — see * Dashboard/Staff/Calendar/Admin on the Team Management screen — see
* docs/implementation-plan.md §5/§8). Owner-only screens (team, business * docs/implementation-plan.md §5/§8). Phase 5 resolved that by having
* settings, etc.) are reached from Settings rather than getting their own * (owner) reuse this same list; Phase 6 gives (owner) its own
* top-level tab, so this one list serves both the (staff) and (owner) * OWNER_NAV_ITEMS below instead, per that phase's own explicit nav
* route groups. * requirement (Dashboard/Calendar/Team/Services/Settings) — see
* docs/phase-6-summary.md, "Owner navigation".
* *
* Icons are string keys, not component references — this module is * Icons are string keys, not component references — this module is
* imported by Server Component layouts, and a Lucide icon component isn't * imported by Server Component layouts, and a Lucide icon component isn't
@@ -21,3 +22,12 @@ export const STAFF_NAV_ITEMS: BottomNavItem[] = [
{ href: "/staff/clients", label: "Clients", icon: "users" }, { href: "/staff/clients", label: "Clients", icon: "users" },
{ href: "/staff/settings", label: "Settings", icon: "settings" }, { href: "/staff/settings", label: "Settings", icon: "settings" },
]; ];
/** The (owner) route group's own nav — distinct from STAFF_NAV_ITEMS per the Phase 6 brief. "Team" covers staff management (incl. individual staff detail); "Services" covers categories+services; business/booking-rules/loyalty/notifications live under Settings. */
export const OWNER_NAV_ITEMS: BottomNavItem[] = [
{ href: "/owner", label: "Dashboard", icon: "chart-line" },
{ href: "/owner/calendar", label: "Calendar", icon: "calendar-days" },
{ href: "/owner/team", label: "Team", icon: "users" },
{ href: "/owner/services", label: "Services", icon: "scissors" },
{ href: "/owner/settings", label: "Settings", icon: "settings" },
];
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { getOpeningHours, setOpeningHours, updateBusinessProfile } from "../business.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
describe("BusinessService (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("updateBusinessProfile", () => {
it("writes through to the Business and its primary BusinessLocation", async () => {
const result = await updateBusinessProfile(prisma, {
businessId: business.businessId,
profile: {
name: "Renamed Barbershop",
timezone: business.timezone,
currency: "GBP",
locale: "en-GB",
},
location: { addressLine1: "1 New Street", city: "Testville", postalCode: "TE1 1ST", country: "GB" },
});
expect(result.success).toBe(true);
const updated = await prisma.business.findUniqueOrThrow({
where: { id: business.businessId },
include: { locations: true },
});
expect(updated.name).toBe("Renamed Barbershop");
expect(updated.locations[0]?.city).toBe("Testville");
});
it("rejects an empty business name without writing anything", async () => {
const result = await updateBusinessProfile(prisma, {
businessId: business.businessId,
profile: { name: " ", timezone: business.timezone, currency: "GBP", locale: "en-GB" },
location: { addressLine1: "1 New Street", city: "Testville", postalCode: "TE1 1ST", country: "GB" },
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("INVALID_INPUT");
const unchanged = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
expect(unchanged.name).toBe("Test Barbershop");
});
});
describe("setOpeningHours", () => {
it("blocks closing a day that has a future confirmed appointment, and Save Anyway bypasses it", async () => {
const { staffId } = await createTestStaff(business, { workingDays: [Weekday.MONDAY] });
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
endAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const days = [
{ dayOfWeek: Weekday.MONDAY, isClosed: true },
{ dayOfWeek: Weekday.TUESDAY, isClosed: false, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
];
const blocked = await setOpeningHours(prisma, {
businessId: business.businessId,
businessTimezone: business.timezone,
days,
});
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
// The appointment is untouched — never auto-cancelled by tightening hours.
const stillConfirmed = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(stillConfirmed.status).toBe("CONFIRMED");
const acknowledged = await setOpeningHours(prisma, {
businessId: business.businessId,
businessTimezone: business.timezone,
days,
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
const hours = await getOpeningHours(prisma, business.businessId);
const mondayHours = hours.find((h) => h.dayOfWeek === Weekday.MONDAY);
expect(mondayHours?.isClosed).toBe(true);
// The appointment still exists, unmodified, after the "Save Anyway".
const afterSave = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(afterSave.status).toBe("CONFIRMED");
});
it("does not flag a conflict for an appointment that falls inside the new hours", async () => {
const { staffId } = await createTestStaff(business, { workingDays: [Weekday.MONDAY] });
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
endAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const result = await setOpeningHours(prisma, {
businessId: business.businessId,
businessTimezone: business.timezone,
days: [{ dayOfWeek: Weekday.MONDAY, isClosed: false, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 }],
});
expect(result.success).toBe(true);
});
});
});
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { validateBusinessProfileInput, validateOpeningHoursInput } from "../business.service";
describe("validateBusinessProfileInput", () => {
const base = {
name: "Blake Heritage Barbershop",
timezone: "Europe/London",
currency: "GBP",
locale: "en-GB",
};
it("accepts a complete profile", () => {
expect(validateBusinessProfileInput(base).valid).toBe(true);
});
it("rejects an empty name", () => {
const result = validateBusinessProfileInput({ ...base, name: " " });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a missing timezone/currency/locale", () => {
const result = validateBusinessProfileInput({ ...base, timezone: "" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
});
describe("validateOpeningHoursInput", () => {
it("accepts a day marked closed with no times", () => {
const result = validateOpeningHoursInput([{ dayOfWeek: Weekday.SUNDAY, isClosed: true }]);
expect(result.valid).toBe(true);
});
it("requires open/close times for an open day", () => {
const result = validateOpeningHoursInput([{ dayOfWeek: Weekday.MONDAY, isClosed: false }]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects opening time at or after closing time", () => {
const result = validateOpeningHoursInput([
{ dayOfWeek: Weekday.TUESDAY, isClosed: false, opensAtMinutes: 600, closesAtMinutes: 600 },
]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("rejects times outside a single day", () => {
const result = validateOpeningHoursInput([
{ dayOfWeek: Weekday.WEDNESDAY, isClosed: false, opensAtMinutes: 0, closesAtMinutes: 25 * 60 },
]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("rejects a negative opening time", () => {
const result = validateOpeningHoursInput([
{ dayOfWeek: Weekday.THURSDAY, isClosed: false, opensAtMinutes: -30, closesAtMinutes: 600 },
]);
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
});
it("accepts a valid full week mixing open and closed days", () => {
const result = validateOpeningHoursInput([
{ dayOfWeek: Weekday.MONDAY, isClosed: false, opensAtMinutes: 10 * 60, closesAtMinutes: 19 * 60 },
{ dayOfWeek: Weekday.SUNDAY, isClosed: true },
]);
expect(result.valid).toBe(true);
});
});
+259
View File
@@ -0,0 +1,259 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { Weekday } from "@/generated/prisma/enums";
import { calendarDateOf, minutesFromMidnight, weekdayOf } from "@/lib/time/business-time";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
/**
* The business's own profile, address, policies, and opening hours —
* owner-only configuration that sits above everything StaffService and
* BookingRule already cover. Kept separate from BookingRule (scheduling
* policy) and LoyaltyService (loyalty) per the brief's own service
* boundaries; this file only knows about Business/BusinessLocation/
* BusinessSettings/BusinessHours.
*/
export type BusinessServiceErrorCode =
| "INVALID_INPUT"
| "INVALID_TIME_RANGE"
| "APPOINTMENT_CONFLICTS";
export interface BusinessProfileInput {
name: string;
description?: string;
logoUrl?: string;
phone?: string;
email?: string;
website?: string;
instagramUrl?: string;
facebookUrl?: string;
timezone: string;
currency: string;
locale: string;
}
export interface BusinessLocationInput {
addressLine1: string;
addressLine2?: string;
city: string;
region?: string;
postalCode: string;
country: string;
latitude?: number;
longitude?: number;
}
export interface BusinessSettingsInput {
cancellationPolicy?: string;
lateArrivalPolicy?: string;
bookingTerms?: string;
privacyPolicyUrl?: string;
}
export function validateBusinessProfileInput(
input: BusinessProfileInput,
): ValidationResult<BusinessServiceErrorCode> {
if (!input.name.trim()) {
return fail({ code: "INVALID_INPUT", message: "Business name is required." });
}
if (!input.timezone.trim() || !input.currency.trim() || !input.locale.trim()) {
return fail({ code: "INVALID_INPUT", message: "Timezone, currency, and locale are required." });
}
return ok();
}
export async function getBusinessProfile(prisma: PrismaClient, businessId: string) {
return prisma.business.findUnique({
where: { id: businessId },
include: {
locations: { where: { isPrimary: true }, take: 1 },
settings: true,
},
});
}
export async function updateBusinessProfile(
prisma: PrismaClient,
params: {
businessId: string;
profile: BusinessProfileInput;
location: BusinessLocationInput;
},
): Promise<{ success: boolean; validation: ValidationResult<BusinessServiceErrorCode> }> {
const validation = validateBusinessProfileInput(params.profile);
if (!validation.valid) return { success: false, validation };
if (!params.location.addressLine1.trim() || !params.location.city.trim() || !params.location.postalCode.trim()) {
return {
success: false,
validation: fail({ code: "INVALID_INPUT", message: "Address line 1, city, and postcode are required." }),
};
}
const existingLocation = await prisma.businessLocation.findFirst({
where: { businessId: params.businessId, isPrimary: true },
});
await prisma.$transaction([
prisma.business.update({
where: { id: params.businessId },
data: { ...params.profile },
}),
existingLocation
? prisma.businessLocation.update({
where: { id: existingLocation.id },
data: { ...params.location },
})
: prisma.businessLocation.create({
data: { businessId: params.businessId, isPrimary: true, ...params.location },
}),
]);
return { success: true, validation: ok() };
}
export async function updateBusinessSettings(
prisma: PrismaClient,
params: { businessId: string; settings: BusinessSettingsInput },
): Promise<{ success: boolean }> {
await prisma.businessSettings.upsert({
where: { businessId: params.businessId },
create: { businessId: params.businessId, ...params.settings },
update: { ...params.settings },
});
return { success: true };
}
// ── Opening hours ──────────────────────────────────────────────────────
export interface OpeningHoursDayInput {
dayOfWeek: Weekday;
isClosed: boolean;
opensAtMinutes?: number;
closesAtMinutes?: number;
}
export function validateOpeningHoursInput(
days: OpeningHoursDayInput[],
): ValidationResult<BusinessServiceErrorCode> {
for (const day of days) {
if (day.isClosed) continue;
if (day.opensAtMinutes === undefined || day.closesAtMinutes === undefined) {
return fail({
code: "INVALID_INPUT",
message: `${day.dayOfWeek}: opening and closing time are required for an open day.`,
});
}
if (day.opensAtMinutes < 0 || day.closesAtMinutes > 24 * 60) {
return fail({
code: "INVALID_TIME_RANGE",
message: `${day.dayOfWeek}: times must fall within a single day.`,
});
}
if (day.opensAtMinutes >= day.closesAtMinutes) {
return fail({
code: "INVALID_TIME_RANGE",
message: `${day.dayOfWeek}: opening time must be before closing time.`,
});
}
}
return ok();
}
export async function getOpeningHours(prisma: PrismaClient, businessId: string) {
return prisma.businessHours.findMany({ where: { businessId }, orderBy: { dayOfWeek: "asc" } });
}
const APPOINTMENT_CONFLICT_INCLUDE = {
services: { include: { service: true } },
customer: true,
staff: { include: { user: { select: { name: true } } } },
} as const;
/**
* Future, non-cancelled appointments that would fall outside the proposed
* hours — either on a day newly marked closed, or before/after the new
* open/close window on a day that stays open. Mirrors the Phase 5
* break/time-off conflict pattern: this never cancels anything itself, it
* only reports what the caller should show before asking for
* acknowledgement.
*/
export async function findOpeningHoursConflicts(
prisma: PrismaClient,
params: { businessId: string; businessTimezone: string; days: OpeningHoursDayInput[] },
) {
const appointments = await prisma.appointment.findMany({
where: {
businessId: params.businessId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
},
include: APPOINTMENT_CONFLICT_INCLUDE,
orderBy: { startAt: "asc" },
});
const byDay = new Map(params.days.map((d) => [d.dayOfWeek, d]));
return appointments.filter((a) => {
const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone));
const proposed = byDay.get(weekday);
if (!proposed) return false;
if (proposed.isClosed) return true;
const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
return aStart < (proposed.opensAtMinutes ?? 0) || aEnd > (proposed.closesAtMinutes ?? 24 * 60);
});
}
export async function setOpeningHours(
prisma: PrismaClient,
params: {
businessId: string;
businessTimezone: string;
days: OpeningHoursDayInput[];
acknowledgeConflicts?: boolean;
},
) {
const validation = validateOpeningHoursInput(params.days);
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findOpeningHoursConflicts(prisma, {
businessId: params.businessId,
businessTimezone: params.businessTimezone,
days: params.days,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "These hours would leave existing appointments outside the business's open hours.",
}),
};
}
}
await prisma.$transaction(
params.days.map((day) =>
prisma.businessHours.upsert({
where: { businessId_dayOfWeek: { businessId: params.businessId, dayOfWeek: day.dayOfWeek } },
create: {
businessId: params.businessId,
dayOfWeek: day.dayOfWeek,
isClosed: day.isClosed,
opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
},
update: {
isClosed: day.isClosed,
opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
},
}),
),
);
return { success: true as const, conflicts: [], validation: ok() };
}
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createCategory,
deleteCategory,
findServiceUsageConflicts,
updateService,
} from "../service-catalogue.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("ServiceCatalogueService (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("categories", () => {
it("blocks deleting a category that still has services", async () => {
// business.categoryId already owns the fixture haircut/beard-trim services.
const result = await deleteCategory(prisma, { categoryId: business.categoryId, businessId: business.businessId });
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("CATEGORY_HAS_SERVICES");
const stillExists = await prisma.serviceCategory.findUnique({ where: { id: business.categoryId } });
expect(stillExists).not.toBeNull();
});
it("allows deleting an empty category", async () => {
const created = await createCategory(prisma, { businessId: business.businessId, input: { name: "Empty" } });
if (!created.success) throw new Error("setup failed");
const result = await deleteCategory(prisma, { categoryId: created.category.id, businessId: business.businessId });
expect(result.success).toBe(true);
const gone = await prisma.serviceCategory.findUnique({ where: { id: created.category.id } });
expect(gone).toBeNull();
});
it("scopes deletion to the given business — a category from a different business is not found", async () => {
const otherBusiness = await createTestBusiness();
try {
const result = await deleteCategory(prisma, {
categoryId: otherBusiness.categoryId,
businessId: business.businessId,
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("CATEGORY_NOT_FOUND");
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
});
describe("services", () => {
it("editing a service's price never rewrites a past appointment's priceAtBooking snapshot", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const pastAppointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "COMPLETED",
startAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
include: { services: true },
});
const updated = await updateService(prisma, {
serviceId: business.haircutServiceId,
businessId: business.businessId,
input: {
categoryId: business.categoryId,
name: "Haircut",
durationMinutes: 45,
price: "35.00",
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
allowOnlineBooking: true,
},
});
expect(updated.success).toBe(true);
const snapshot = await prisma.appointmentService.findUniqueOrThrow({
where: { id: pastAppointment.services[0]!.id },
});
expect(Number(snapshot.priceAtBooking)).toBe(20);
expect(snapshot.durationAtBookingMinutes).toBe(30);
const liveService = await prisma.service.findUniqueOrThrow({ where: { id: business.haircutServiceId } });
expect(Number(liveService.price)).toBe(35);
expect(liveService.durationMinutes).toBe(45);
});
it("findServiceUsageConflicts finds only future, non-cancelled appointments booking that service", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const future = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
// Cancelled — must not appear in conflicts.
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CANCELLED",
startAt: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const conflicts = await findServiceUsageConflicts(prisma, {
serviceId: business.haircutServiceId,
businessId: business.businessId,
});
expect(conflicts.map((c) => c.id)).toEqual([future.id]);
});
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { validateCategoryInput, validateServiceInput } from "../service-catalogue.service";
describe("validateCategoryInput", () => {
it("accepts a valid category name", () => {
expect(validateCategoryInput({ name: "Beard Services" }).valid).toBe(true);
});
it("rejects an empty/whitespace-only name", () => {
const result = validateCategoryInput({ name: " " });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
});
describe("validateServiceInput", () => {
const base = {
categoryId: "cat-1",
name: "Haircut & Style",
durationMinutes: 30,
price: "22.50",
bufferBeforeMinutes: 0,
bufferAfterMinutes: 5,
allowOnlineBooking: true,
};
it("accepts a fully valid service", () => {
expect(validateServiceInput(base).valid).toBe(true);
});
it("rejects an empty name", () => {
const result = validateServiceInput({ ...base, name: "" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a missing category", () => {
const result = validateServiceInput({ ...base, categoryId: "" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a zero or negative duration", () => {
const result = validateServiceInput({ ...base, durationMinutes: 0 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_DURATION");
});
it("rejects a non-integer duration", () => {
const result = validateServiceInput({ ...base, durationMinutes: 30.5 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_DURATION");
});
it.each(["free", "-5.00", "5.999", "5,00", ""])("rejects an invalid price %s", (price) => {
const result = validateServiceInput({ ...base, price });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_PRICE");
});
it.each(["0", "22", "22.5", "22.50"])("accepts a valid decimal price %s", (price) => {
expect(validateServiceInput({ ...base, price }).valid).toBe(true);
});
it("rejects a negative buffer", () => {
const result = validateServiceInput({ ...base, bufferBeforeMinutes: -1 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
});
@@ -0,0 +1,322 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
/**
* Service categories and services — the owner-facing catalogue. Staff
* eligibility (which barber can perform which service) stays in
* StaffService (see docs/phase-6-summary.md, "Domain services") since it's
* staff-scoped data, not catalogue data; this file only owns
* ServiceCategory/Service themselves.
*
* Money is handled as strings end to end — a price never passes through a
* JS `number` on its way into Prisma's `Decimal` column, so there's no
* floating-point rounding step to get wrong.
*/
export type ServiceCatalogueErrorCode =
| "INVALID_INPUT"
| "INVALID_PRICE"
| "INVALID_DURATION"
| "CATEGORY_NOT_FOUND"
| "CATEGORY_HAS_SERVICES"
| "SERVICE_NOT_FOUND"
| "APPOINTMENT_CONFLICTS";
const DECIMAL_PRICE = /^\d+(\.\d{1,2})?$/;
// ── Categories ──────────────────────────────────────────────────────────
export interface CategoryInput {
name: string;
description?: string;
}
export function validateCategoryInput(input: CategoryInput): ValidationResult<ServiceCatalogueErrorCode> {
if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Category name is required." });
return ok();
}
export async function listCategories(prisma: PrismaClient, businessId: string) {
return prisma.serviceCategory.findMany({
where: { businessId },
orderBy: { displayOrder: "asc" },
include: { services: { orderBy: { displayOrder: "asc" } } },
});
}
export async function createCategory(
prisma: PrismaClient,
params: { businessId: string; input: CategoryInput },
) {
const validation = validateCategoryInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const maxOrder = await prisma.serviceCategory.aggregate({
where: { businessId: params.businessId },
_max: { displayOrder: true },
});
const category = await prisma.serviceCategory.create({
data: {
businessId: params.businessId,
name: params.input.name.trim(),
displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
},
});
return { success: true as const, category, validation: ok() };
}
export async function updateCategory(
prisma: PrismaClient,
params: { categoryId: string; businessId: string; input: CategoryInput },
) {
const validation = validateCategoryInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const existing = await prisma.serviceCategory.findFirst({
where: { id: params.categoryId, businessId: params.businessId },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
};
}
await prisma.serviceCategory.update({
where: { id: params.categoryId },
data: { name: params.input.name.trim() },
});
return { success: true as const, validation: ok() };
}
export async function setCategoryActive(
prisma: PrismaClient,
params: { categoryId: string; businessId: string; isActive: boolean },
): Promise<{ success: boolean }> {
const result = await prisma.serviceCategory.updateMany({
where: { id: params.categoryId, businessId: params.businessId },
data: { isActive: params.isActive },
});
return { success: result.count > 0 };
}
export async function reorderCategories(
prisma: PrismaClient,
params: { businessId: string; orderedCategoryIds: string[] },
): Promise<{ success: boolean }> {
await prisma.$transaction(
params.orderedCategoryIds.map((id, index) =>
prisma.serviceCategory.updateMany({
where: { id, businessId: params.businessId },
data: { displayOrder: index },
}),
),
);
return { success: true };
}
/** Categories with services (active or not) can't be deleted outright — deactivate instead, per the brief's "prefer deactivation over destructive deletion where historical data depends on it." */
export async function deleteCategory(
prisma: PrismaClient,
params: { categoryId: string; businessId: string },
) {
const existing = await prisma.serviceCategory.findFirst({
where: { id: params.categoryId, businessId: params.businessId },
include: { _count: { select: { services: true } } },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
};
}
if (existing._count.services > 0) {
return {
success: false as const,
validation: fail({
code: "CATEGORY_HAS_SERVICES",
message: "This category still has services — move or remove them first, or deactivate the category instead.",
}),
};
}
await prisma.serviceCategory.delete({ where: { id: params.categoryId } });
return { success: true as const, validation: ok() };
}
// ── Services ────────────────────────────────────────────────────────────
export interface ServiceInput {
categoryId: string;
name: string;
description?: string;
durationMinutes: number;
/** Decimal string, e.g. "22.50" — never a JS number, see file header. */
price: string;
bufferBeforeMinutes: number;
bufferAfterMinutes: number;
allowOnlineBooking: boolean;
}
export function validateServiceInput(input: ServiceInput): ValidationResult<ServiceCatalogueErrorCode> {
if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Service name is required." });
if (!input.categoryId) return fail({ code: "INVALID_INPUT", message: "Choose a category." });
if (!Number.isInteger(input.durationMinutes) || input.durationMinutes <= 0) {
return fail({ code: "INVALID_DURATION", message: "Duration must be a positive number of minutes." });
}
if (!DECIMAL_PRICE.test(input.price)) {
return fail({ code: "INVALID_PRICE", message: "Enter a price like 22.50, with at most two decimal places." });
}
if (
!Number.isInteger(input.bufferBeforeMinutes) ||
!Number.isInteger(input.bufferAfterMinutes) ||
input.bufferBeforeMinutes < 0 ||
input.bufferAfterMinutes < 0
) {
return fail({ code: "INVALID_INPUT", message: "Buffers must be zero or a positive number of minutes." });
}
return ok();
}
export async function listServices(
prisma: PrismaClient,
params: { businessId: string; categoryId?: string; q?: string },
) {
return prisma.service.findMany({
where: {
businessId: params.businessId,
...(params.categoryId ? { categoryId: params.categoryId } : {}),
...(params.q ? { name: { contains: params.q, mode: "insensitive" } } : {}),
},
include: { category: true },
orderBy: [{ categoryId: "asc" }, { displayOrder: "asc" }],
});
}
export async function createService(
prisma: PrismaClient,
params: { businessId: string; input: ServiceInput },
) {
const validation = validateServiceInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const category = await prisma.serviceCategory.findFirst({
where: { id: params.input.categoryId, businessId: params.businessId },
});
if (!category) {
return {
success: false as const,
validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
};
}
const maxOrder = await prisma.service.aggregate({
where: { businessId: params.businessId, categoryId: params.input.categoryId },
_max: { displayOrder: true },
});
const service = await prisma.service.create({
data: {
businessId: params.businessId,
categoryId: params.input.categoryId,
name: params.input.name.trim(),
description: params.input.description?.trim() || null,
durationMinutes: params.input.durationMinutes,
price: params.input.price,
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
bufferAfterMinutes: params.input.bufferAfterMinutes,
allowOnlineBooking: params.input.allowOnlineBooking,
displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
},
});
return { success: true as const, service, validation: ok() };
}
const APPOINTMENT_CONFLICT_INCLUDE = {
customer: true,
staff: { include: { user: { select: { name: true } } } },
} as const;
/** Future, non-cancelled appointments that booked this exact service — informational for an edit/deactivation confirmation, never auto-resolved. Historical AppointmentService rows (priceAtBooking/durationAtBookingMinutes) are untouched regardless. */
export async function findServiceUsageConflicts(
prisma: PrismaClient,
params: { serviceId: string; businessId: string },
) {
return prisma.appointment.findMany({
where: {
businessId: params.businessId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
services: { some: { serviceId: params.serviceId } },
},
include: APPOINTMENT_CONFLICT_INCLUDE,
orderBy: { startAt: "asc" },
});
}
export async function updateService(
prisma: PrismaClient,
params: { serviceId: string; businessId: string; input: ServiceInput },
) {
const validation = validateServiceInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const existing = await prisma.service.findFirst({
where: { id: params.serviceId, businessId: params.businessId },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "SERVICE_NOT_FOUND", message: "Service not found." }),
};
}
// Price/duration snapshots on past AppointmentService rows are never
// rewritten — this only ever changes the live Service row future
// bookings will read at booking time. See docs/phase-6-summary.md.
await prisma.service.update({
where: { id: params.serviceId },
data: {
categoryId: params.input.categoryId,
name: params.input.name.trim(),
description: params.input.description?.trim() || null,
durationMinutes: params.input.durationMinutes,
price: params.input.price,
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
bufferAfterMinutes: params.input.bufferAfterMinutes,
allowOnlineBooking: params.input.allowOnlineBooking,
},
});
return { success: true as const, validation: ok() };
}
export async function setServiceActive(
prisma: PrismaClient,
params: { serviceId: string; businessId: string; isActive: boolean },
): Promise<{ success: boolean }> {
const result = await prisma.service.updateMany({
where: { id: params.serviceId, businessId: params.businessId },
data: { isActive: params.isActive },
});
return { success: result.count > 0 };
}
export async function reorderServices(
prisma: PrismaClient,
params: { businessId: string; categoryId: string; orderedServiceIds: string[] },
): Promise<{ success: boolean }> {
await prisma.$transaction(
params.orderedServiceIds.map((id, index) =>
prisma.service.updateMany({
where: { id, businessId: params.businessId, categoryId: params.categoryId },
data: { displayOrder: index },
}),
),
);
return { success: true };
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { validateProgrammeConfig } from "../loyalty.service";
import type { ProgrammeConfigInput } from "../loyalty.service";
const base: ProgrammeConfigInput = {
isEnabled: true,
stampsRequired: 10,
rewardType: "PERCENTAGE",
rewardValue: "50",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
};
describe("validateProgrammeConfig", () => {
it("accepts a valid percentage-reward configuration", () => {
expect(validateProgrammeConfig(base).valid).toBe(true);
});
it("rejects a stamps-required of zero", () => {
const result = validateProgrammeConfig({ ...base, stampsRequired: 0 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("allows an incomplete configuration while disabled", () => {
const result = validateProgrammeConfig({ ...base, isEnabled: false, rewardValue: undefined });
expect(result.valid).toBe(true);
});
it.each([0, 101, -5])("rejects an out-of-range percentage when enabled (%s)", (pct) => {
const result = validateProgrammeConfig({ ...base, rewardValue: String(pct) });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_PERCENTAGE");
});
it("rejects a missing percentage value when enabled", () => {
const result = validateProgrammeConfig({ ...base, rewardValue: undefined });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_PERCENTAGE");
});
it("accepts a valid fixed-amount reward", () => {
const result = validateProgrammeConfig({ ...base, rewardType: "FIXED_AMOUNT", rewardValue: "10.00" });
expect(result.valid).toBe(true);
});
it("rejects a negative fixed-amount reward", () => {
const result = validateProgrammeConfig({ ...base, rewardType: "FIXED_AMOUNT", rewardValue: "-1" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_VALUE");
});
it("accepts a free-service reward with a service chosen", () => {
const result = validateProgrammeConfig({ ...base, rewardType: "FREE_SERVICE", freeServiceId: "svc-1" });
expect(result.valid).toBe(true);
});
it("rejects a free-service reward with no service chosen", () => {
const result = validateProgrammeConfig({ ...base, rewardType: "FREE_SERVICE", freeServiceId: undefined });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
});
});
@@ -0,0 +1,248 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
redeemReward,
setEligibleServices,
updateProgrammeConfig,
} from "../loyalty.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("LoyaltyService — owner configuration and redemption (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("updateProgrammeConfig", () => {
it("creates the LoyaltyProgramme row on first save and updates it on the next", async () => {
const first = await updateProgrammeConfig(prisma, {
businessId: business.businessId,
input: {
isEnabled: true,
stampsRequired: 8,
rewardType: "PERCENTAGE",
rewardValue: "25",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
},
});
expect(first.success).toBe(true);
const programme = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
expect(programme.stampsRequired).toBe(8);
expect(Number(programme.rewardValue)).toBe(25);
const second = await updateProgrammeConfig(prisma, {
businessId: business.businessId,
input: {
isEnabled: true,
stampsRequired: 12,
rewardType: "PERCENTAGE",
rewardValue: "40",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
},
});
expect(second.success).toBe(true);
const updated = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
expect(updated.stampsRequired).toBe(12);
expect(Number(updated.rewardValue)).toBe(40);
});
it("rejects enabling a FREE_SERVICE reward pointing at an inactive service", async () => {
const inactiveService = await prisma.service.update({
where: { id: business.beardTrimServiceId },
data: { isActive: false },
});
const result = await updateProgrammeConfig(prisma, {
businessId: business.businessId,
input: {
isEnabled: true,
stampsRequired: 10,
rewardType: "FREE_SERVICE",
freeServiceId: inactiveService.id,
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
},
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
});
});
describe("setEligibleServices", () => {
it("replaces the eligible-services list wholesale", async () => {
const programme = await prisma.loyaltyProgramme.create({
data: { businessId: business.businessId, stampsRequired: 10, rewardType: "PERCENTAGE", rewardValue: 50 },
});
await setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: [business.haircutServiceId] });
let rows = await prisma.loyaltyEligibleService.findMany({ where: { loyaltyProgrammeId: programme.id } });
expect(rows.map((r) => r.serviceId)).toEqual([business.haircutServiceId]);
await setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: [business.beardTrimServiceId] });
rows = await prisma.loyaltyEligibleService.findMany({ where: { loyaltyProgrammeId: programme.id } });
expect(rows.map((r) => r.serviceId)).toEqual([business.beardTrimServiceId]);
});
});
describe("redeemReward", () => {
async function setUpRedeemableReward() {
const programme = await prisma.loyaltyProgramme.create({
data: { businessId: business.businessId, stampsRequired: 10, rewardType: "PERCENTAGE", rewardValue: 50 },
});
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const account = await prisma.loyaltyAccount.create({
data: { customerId, loyaltyProgrammeId: programme.id, currentStamps: 10 },
});
const reward = await prisma.loyaltyReward.create({
data: { loyaltyAccountId: account.id, rewardType: "PERCENTAGE", value: 50, label: "50% Off", status: "AVAILABLE" },
});
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
return { staffId, account, reward, appointment };
}
it("applies the discount, marks the reward REDEEMED, and deducts stamps — all atomically", async () => {
const { staffId, account, reward, appointment } = await setUpRedeemableReward();
const result = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(result.success).toBe(true);
expect(result.discountAmount).toBe("10.00"); // 50% of a 20.00 haircut
const updatedReward = await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: reward.id } });
expect(updatedReward.status).toBe("REDEEMED");
expect(updatedReward.redeemedByStaffId).toBe(staffId);
expect(updatedReward.appliedToAppointmentId).toBe(appointment.id);
const updatedAccount = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
expect(updatedAccount.currentStamps).toBe(0);
const updatedAppointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(Number(updatedAppointment.discountAmount)).toBe(10);
});
it("prevents redeeming the same reward twice — the second attempt reports REWARD_NOT_AVAILABLE, not a double deduction", async () => {
const { staffId, account, reward, appointment } = await setUpRedeemableReward();
const first = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(first.success).toBe(true);
// A second appointment to apply to, since the first is now discounted.
const { customerId } = await createTestCustomer(business);
void customerId;
const secondAppointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId: account.customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const second = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: secondAppointment.id,
staffId,
businessId: business.businessId,
});
expect(second.success).toBe(false);
expect(second.validation.issues[0]?.code).toBe("REWARD_NOT_AVAILABLE");
// Stamps weren't deducted a second time.
const account2 = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
expect(account2.currentStamps).toBe(0);
// The second appointment was never discounted.
const unaffected = await prisma.appointment.findUniqueOrThrow({ where: { id: secondAppointment.id } });
expect(unaffected.discountAmount).toBeNull();
});
it("rejects applying a reward to an appointment that already has a discount", async () => {
const { staffId, reward, appointment } = await setUpRedeemableReward();
await prisma.appointment.update({ where: { id: appointment.id }, data: { discountAmount: 5 } });
const result = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("APPOINTMENT_NOT_ELIGIBLE");
});
it("rejects applying a reward to a different customer's appointment", async () => {
const { staffId, reward } = await setUpRedeemableReward();
const { customerId: otherCustomerId } = await createTestCustomer(business);
const otherAppointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId: otherCustomerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const result = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: otherAppointment.id,
staffId,
businessId: business.businessId,
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("REWARD_CUSTOMER_MISMATCH");
});
});
});
+295 -6
View File
@@ -9,11 +9,11 @@ import type { ValidationResult } from "@/lib/validation-result";
* without creating the matching transaction in the same call, and nothing * without creating the matching transaction in the same call, and nothing
* outside this file should touch either table directly. * outside this file should touch either table directly.
* *
* Reward reset-on-redemption (see LoyaltyReward's own schema comment) is * Reward redemption (Phase 6) follows the same discipline: redeeming a
* deliberately not implemented here — redeeming a reward isn't one of the * reward, deducting the consumed stamps from the cached total, and
* Phase 5 barber capabilities, so `currentStamps` is allowed to sit at or * recording the ledger entry all happen inside one `$transaction` — see
* above `stampsRequired` once a reward becomes available. Redemption is a * `redeemReward`. This was a documented, deliberate Phase 5 limitation,
* documented Phase 5 limitation, not an oversight. * not an oversight; it's implemented now.
*/ */
export type LoyaltyErrorCode = export type LoyaltyErrorCode =
@@ -24,7 +24,15 @@ export type LoyaltyErrorCode =
| "REASON_REQUIRED" | "REASON_REQUIRED"
| "INVALID_AMOUNT" | "INVALID_AMOUNT"
| "APPOINTMENT_NOT_FOUND" | "APPOINTMENT_NOT_FOUND"
| "APPOINTMENT_NOT_COMPLETED"; | "APPOINTMENT_NOT_COMPLETED"
| "INVALID_INPUT"
| "INVALID_PERCENTAGE"
| "INVALID_VALUE"
| "FREE_SERVICE_REQUIRED"
| "REWARD_NOT_FOUND"
| "REWARD_NOT_AVAILABLE"
| "REWARD_CUSTOMER_MISMATCH"
| "APPOINTMENT_NOT_ELIGIBLE";
export interface LoyaltySummary { export interface LoyaltySummary {
isEnabled: boolean; isEnabled: boolean;
@@ -451,3 +459,284 @@ export async function getLoyaltyHistory(
}, },
}); });
} }
// ── Owner: programme configuration ─────────────────────────────────────
export interface ProgrammeConfigInput {
isEnabled: boolean;
stampsRequired: number;
rewardType: "PERCENTAGE" | "FIXED_AMOUNT" | "FREE_SERVICE";
/** Decimal string — see ServiceCatalogueService's file header for why money is never a JS number. Required for PERCENTAGE/FIXED_AMOUNT, ignored for FREE_SERVICE. */
rewardValue?: string;
rewardLabel?: string;
maxRewardValue?: string;
freeServiceId?: string;
autoApplyReward: boolean;
requiresStaffApproval: boolean;
stampsExpireAfterDays?: number;
rewardExpiresAfterDays?: number;
earnOnCancelled: boolean;
earnOnNoShow: boolean;
cardBackgroundColor?: string;
cardBackgroundImageUrl?: string;
stampIcon?: string;
termsLine?: string;
}
/** Only enforced when isEnabled — a half-configured, disabled draft is fine (the owner is still working on it); a half-configured, *enabled* programme would start awarding rewards nobody can redeem correctly. */
export function validateProgrammeConfig(input: ProgrammeConfigInput): ValidationResult<LoyaltyErrorCode> {
if (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1) {
return fail({ code: "INVALID_INPUT", message: "Stamps required must be at least 1." });
}
if (!input.isEnabled) return ok();
if (input.rewardType === "PERCENTAGE") {
const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN;
if (!Number.isFinite(value) || value <= 0 || value > 100) {
return fail({ code: "INVALID_PERCENTAGE", message: "Percentage must be between 1 and 100." });
}
} else if (input.rewardType === "FIXED_AMOUNT") {
const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN;
if (!Number.isFinite(value) || value < 0) {
return fail({ code: "INVALID_VALUE", message: "Enter a fixed reward amount of zero or more." });
}
} else if (input.rewardType === "FREE_SERVICE" && !input.freeServiceId) {
return fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose which service is free." });
}
return ok();
}
export async function getProgrammeConfig(prisma: PrismaClient, businessId: string) {
return prisma.loyaltyProgramme.findUnique({
where: { businessId },
include: {
eligibleServices: { include: { service: true } },
eligibleStaff: true,
freeService: true,
},
});
}
export async function updateProgrammeConfig(
prisma: PrismaClient,
params: { businessId: string; input: ProgrammeConfigInput },
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode> }> {
const validation = validateProgrammeConfig(params.input);
if (!validation.valid) return { success: false, validation };
if (params.input.isEnabled && params.input.rewardType === "FREE_SERVICE" && params.input.freeServiceId) {
const service = await prisma.service.findFirst({
where: { id: params.input.freeServiceId, businessId: params.businessId, isActive: true },
});
if (!service) {
return {
success: false,
validation: fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose an active service." }),
};
}
}
const data = {
isEnabled: params.input.isEnabled,
stampsRequired: params.input.stampsRequired,
rewardType: params.input.rewardType,
rewardValue: params.input.rewardType === "FREE_SERVICE" ? null : (params.input.rewardValue ?? null),
rewardLabel: params.input.rewardLabel?.trim() || null,
maxRewardValue: params.input.maxRewardValue || null,
freeServiceId: params.input.rewardType === "FREE_SERVICE" ? (params.input.freeServiceId ?? null) : null,
autoApplyReward: params.input.autoApplyReward,
requiresStaffApproval: params.input.requiresStaffApproval,
stampsExpireAfterDays: params.input.stampsExpireAfterDays ?? null,
rewardExpiresAfterDays: params.input.rewardExpiresAfterDays ?? null,
earnOnCancelled: params.input.earnOnCancelled,
earnOnNoShow: params.input.earnOnNoShow,
cardBackgroundColor: params.input.cardBackgroundColor || null,
cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null,
stampIcon: params.input.stampIcon || null,
termsLine: params.input.termsLine?.trim() || null,
};
await prisma.loyaltyProgramme.upsert({
where: { businessId: params.businessId },
create: { businessId: params.businessId, ...data },
update: data,
});
return { success: true, validation: ok() };
}
export async function setEligibleServices(
prisma: PrismaClient,
params: { loyaltyProgrammeId: string; serviceIds: string[] },
): Promise<{ success: true }> {
await prisma.$transaction([
prisma.loyaltyEligibleService.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }),
prisma.loyaltyEligibleService.createMany({
data: params.serviceIds.map((serviceId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, serviceId })),
}),
]);
return { success: true };
}
/** LoyaltyEligibleStaff.staffId has no FK relation in the schema (see schema.prisma) — callers that need staff names for display fetch StaffProfile rows separately and join by id in memory. */
export async function setEligibleStaff(
prisma: PrismaClient,
params: { loyaltyProgrammeId: string; staffIds: string[] },
): Promise<{ success: true }> {
await prisma.$transaction([
prisma.loyaltyEligibleStaff.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }),
prisma.loyaltyEligibleStaff.createMany({
data: params.staffIds.map((staffId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, staffId })),
}),
]);
return { success: true };
}
// ── Reward redemption ───────────────────────────────────────────────────
/** Appointments this reward could be applied to right now — same customer, not cancelled, no reward already applied. Shown so staff pick a real, current appointment rather than typing an id. */
export async function listEligibleAppointmentsForReward(
prisma: PrismaClient,
params: { customerId: string; businessId: string },
) {
return prisma.appointment.findMany({
where: {
customerId: params.customerId,
businessId: params.businessId,
status: { notIn: ["CANCELLED"] },
discountAmount: null,
},
include: { services: { include: { service: true } }, staff: { include: { user: { select: { name: true } } } } },
orderBy: { startAt: "desc" },
take: 10,
});
}
/**
* Redeems an AVAILABLE reward against a specific appointment — the one
* place `LoyaltyReward.status`, `LoyaltyAccount.currentStamps`, and
* `Appointment.discountAmount` ever change together. The status flip uses
* a conditional `updateMany` (`WHERE status = 'AVAILABLE'`) inside the
* transaction rather than a read-then-write, so two simultaneous
* redemption attempts can't both succeed — the loser sees
* REWARD_NOT_AVAILABLE, not a corrupted double-deduction.
*
* Neither the reward nor the stamps-required count is snapshotted at
* *earn* time — redemption always uses the loyalty programme's current
* configuration (current stampsRequired, current freeServiceId for a
* FREE_SERVICE reward). A documented simplification: if the owner
* reconfigures the programme between a reward being earned and redeemed,
* redemption reflects the new configuration. See docs/phase-6-summary.md.
*/
export async function redeemReward(
prisma: PrismaClient,
params: { rewardId: string; appointmentId: string; staffId: string; businessId: string },
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode>; discountAmount?: string }> {
const reward = await prisma.loyaltyReward.findUnique({
where: { id: params.rewardId },
include: { loyaltyAccount: { include: { loyaltyProgramme: true } } },
});
if (!reward || reward.loyaltyAccount.loyaltyProgramme.businessId !== params.businessId) {
return { success: false, validation: fail({ code: "REWARD_NOT_FOUND", message: "Reward not found." }) };
}
if (reward.status !== "AVAILABLE") {
return {
success: false,
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed or has expired." }),
};
}
const appointment = await prisma.appointment.findFirst({
where: { id: params.appointmentId, businessId: params.businessId },
include: { services: true },
});
if (!appointment) {
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
}
if (appointment.customerId !== reward.loyaltyAccount.customerId) {
return {
success: false,
validation: fail({ code: "REWARD_CUSTOMER_MISMATCH", message: "This reward belongs to a different customer." }),
};
}
if (appointment.status === "CANCELLED") {
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "Can't apply a reward to a cancelled appointment." }),
};
}
if (appointment.discountAmount !== null) {
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "A reward has already been applied to this appointment." }),
};
}
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
let discountAmount: number;
if (reward.rewardType === "PERCENTAGE") {
discountAmount = Math.round(subtotal * (Number(reward.value ?? 0) / 100) * 100) / 100;
} else if (reward.rewardType === "FIXED_AMOUNT") {
discountAmount = Number(reward.value ?? 0);
} else {
const freeLine = appointment.services.find((s) => s.serviceId === reward.loyaltyAccount.loyaltyProgramme.freeServiceId);
if (!freeLine) {
return {
success: false,
validation: fail({
code: "APPOINTMENT_NOT_ELIGIBLE",
message: "This appointment doesn't include the service this reward covers.",
}),
};
}
discountAmount = Number(freeLine.priceAtBooking);
}
discountAmount = Math.min(Math.max(discountAmount, 0), subtotal);
const discountAmountString = discountAmount.toFixed(2);
const stampsRequired = reward.loyaltyAccount.loyaltyProgramme.stampsRequired;
const { alreadyRedeemed } = await prisma.$transaction(async (tx) => {
const updated = await tx.loyaltyReward.updateMany({
where: { id: params.rewardId, status: "AVAILABLE" },
data: {
status: "REDEEMED",
redeemedAt: new Date(),
redeemedByStaffId: params.staffId,
appliedToAppointmentId: params.appointmentId,
},
});
if (updated.count === 0) return { alreadyRedeemed: true };
const account = await tx.loyaltyAccount.findUniqueOrThrow({ where: { id: reward.loyaltyAccountId } });
const clampedDeduction = Math.min(stampsRequired, account.currentStamps);
await tx.loyaltyAccount.update({
where: { id: account.id },
data: { currentStamps: { decrement: clampedDeduction } },
});
await tx.loyaltyTransaction.create({
data: {
loyaltyAccountId: account.id,
type: "REWARD_REDEEMED",
amount: -clampedDeduction,
appointmentId: params.appointmentId,
authoredByStaffId: params.staffId,
},
});
await tx.appointment.update({
where: { id: params.appointmentId },
data: { discountAmount: discountAmountString },
});
return { alreadyRedeemed: false };
});
if (alreadyRedeemed) {
return {
success: false,
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed." }),
};
}
return { success: true, validation: ok(), discountAmount: discountAmountString };
}
@@ -0,0 +1,272 @@
import type { PrismaClient } from "@/generated/prisma/client";
import {
calendarDateToUtcMidnight,
minutesFromMidnight,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { validateProgrammeConfig } from "@/services/loyalty/loyalty.service";
import { listAppointments } from "@/services/scheduling/appointment.service";
/**
* Read-only aggregation for the owner dashboard — every function here
* reads across services/appointments/staff and returns a typed summary;
* nothing in this file writes anything. Per the brief: only exists because
* the dashboard's queries are genuinely more than "call one existing
* service and render it" — this is not a general owner data-access layer,
* and no other feature should import from it.
*/
const ACTIVE_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"] as const;
export interface DashboardTotals {
total: number;
completed: number;
cancelled: number;
noShow: number;
inProgress: number;
upcoming: number;
}
export interface PopularService {
serviceId: string;
name: string;
count: number;
}
export interface ConfigWarning {
code: string;
message: string;
}
export interface DashboardSummary {
totals: DashboardTotals;
staffWorkingToday: { staffId: string; name: string }[];
staffOnTimeOffToday: { staffId: string; name: string; reason: string | null }[];
/** Booked minutes vs. total working minutes across staff working today — an estimate, not a financial figure, and always labelled as such by the caller. Null when no staff are working today (division by zero). */
occupancy: { bookedMinutes: number; availableMinutes: number; percent: number } | null;
popularServices: PopularService[];
recentLoyaltyActivity: Awaited<ReturnType<typeof getRecentLoyaltyActivity>>;
customersWithAvailableRewards: number;
warnings: ConfigWarning[];
}
async function getRecentLoyaltyActivity(prisma: PrismaClient, businessId: string, limit: number) {
return prisma.loyaltyTransaction.findMany({
where: { loyaltyAccount: { loyaltyProgramme: { businessId } } },
orderBy: { createdAt: "desc" },
take: limit,
include: {
loyaltyAccount: { include: { customer: { select: { firstName: true, lastName: true } } } },
authoredByStaff: { include: { user: { select: { name: true } } } },
},
});
}
export async function getDashboardSummary(
prisma: PrismaClient,
params: { businessId: string; businessTimezone: string; date: CalendarDate },
): Promise<DashboardSummary> {
const appointments = await listAppointments(prisma, {
businessId: params.businessId,
fromDate: params.date,
toDate: params.date,
businessTimezone: params.businessTimezone,
});
const totals: DashboardTotals = {
total: appointments.length,
completed: appointments.filter((a) => a.status === "COMPLETED").length,
cancelled: appointments.filter((a) => a.status === "CANCELLED").length,
noShow: appointments.filter((a) => a.status === "NO_SHOW").length,
inProgress: appointments.filter((a) => a.status === "IN_PROGRESS").length,
upcoming: appointments.filter((a) => (ACTIVE_STATUSES as readonly string[]).includes(a.status)).length,
};
const weekday = weekdayOf(params.date);
const dateUtcMidnight = calendarDateToUtcMidnight(params.date);
const [activeStaff, workingHoursToday, timeOffToday] = await Promise.all([
prisma.staffProfile.findMany({
where: { businessId: params.businessId, status: "ACTIVE" },
include: { user: { select: { name: true } } },
}),
prisma.workingHours.findMany({
where: { staff: { businessId: params.businessId, status: "ACTIVE" }, dayOfWeek: weekday },
}),
prisma.timeOff.findMany({
where: {
staff: { businessId: params.businessId, status: "ACTIVE" },
status: "APPROVED",
startMinutes: null,
startDate: { lte: dateUtcMidnight },
endDate: { gte: dateUtcMidnight },
},
}),
]);
const workingHoursByStaffId = new Map(workingHoursToday.map((h) => [h.staffId, h]));
const timeOffStaffIds = new Set(timeOffToday.map((t) => t.staffId));
const nameByStaffId = new Map(activeStaff.map((s) => [s.id, s.user.name ?? "Staff"]));
const staffWorkingToday = activeStaff
.filter((s) => {
const hours = workingHoursByStaffId.get(s.id);
return hours && !hours.isOff && !timeOffStaffIds.has(s.id);
})
.map((s) => ({ staffId: s.id, name: nameByStaffId.get(s.id) ?? "Staff" }));
const staffOnTimeOffToday = timeOffToday.map((t) => ({
staffId: t.staffId,
name: nameByStaffId.get(t.staffId) ?? "Staff",
reason: t.reason,
}));
let occupancy: DashboardSummary["occupancy"] = null;
if (staffWorkingToday.length > 0) {
const availableMinutes = staffWorkingToday.reduce((sum, s) => {
const hours = workingHoursByStaffId.get(s.staffId);
if (!hours || hours.startMinutes === null || hours.endMinutes === null) return sum;
return sum + (hours.endMinutes - hours.startMinutes);
}, 0);
const bookedMinutes = appointments
.filter((a) => (ACTIVE_STATUSES as readonly string[]).includes(a.status) || a.status === "COMPLETED")
.reduce((sum, a) => sum + minutesFromMidnight(a.endAt, params.businessTimezone) - minutesFromMidnight(a.startAt, params.businessTimezone), 0);
occupancy = {
bookedMinutes,
availableMinutes,
percent: availableMinutes > 0 ? Math.round((bookedMinutes / availableMinutes) * 100) : 0,
};
}
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const recentServiceLines = await prisma.appointmentService.findMany({
where: {
appointment: { businessId: params.businessId, status: "COMPLETED", startAt: { gte: thirtyDaysAgo } },
},
include: { service: { select: { id: true, name: true } } },
});
const serviceCounts = new Map<string, PopularService>();
for (const line of recentServiceLines) {
const existing = serviceCounts.get(line.serviceId);
if (existing) existing.count += 1;
else serviceCounts.set(line.serviceId, { serviceId: line.serviceId, name: line.service.name, count: 1 });
}
const popularServices = [...serviceCounts.values()].sort((a, b) => b.count - a.count).slice(0, 5);
const [recentLoyaltyActivity, customersWithAvailableRewards, warnings] = await Promise.all([
getRecentLoyaltyActivity(prisma, params.businessId, 5),
prisma.loyaltyReward.count({
where: { status: "AVAILABLE", loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } } },
}),
getConfigurationWarnings(prisma, params.businessId),
]);
return {
totals,
staffWorkingToday,
staffOnTimeOffToday,
occupancy,
popularServices,
recentLoyaltyActivity,
customersWithAvailableRewards,
warnings,
};
}
export async function getConfigurationWarnings(prisma: PrismaClient, businessId: string): Promise<ConfigWarning[]> {
const warnings: ConfigWarning[] = [];
const [businessHours, activeStaff, activeServices, loyaltyProgramme] = await Promise.all([
prisma.businessHours.findMany({ where: { businessId } }),
prisma.staffProfile.findMany({
where: { businessId, status: "ACTIVE" },
include: { user: { select: { name: true } }, workingHours: true, services: true },
}),
prisma.service.findMany({
where: { businessId, isActive: true },
include: { staff: { include: { staff: { select: { status: true } } } } },
}),
prisma.loyaltyProgramme.findUnique({ where: { businessId } }),
]);
if (businessHours.length === 0 || businessHours.every((h) => h.isClosed)) {
warnings.push({ code: "NO_OPENING_HOURS", message: "The business has no open days configured." });
}
for (const staff of activeStaff) {
const hasAnyOpenDay = staff.workingHours.some((h) => !h.isOff);
if (!hasAnyOpenDay) {
warnings.push({
code: "STAFF_NO_WORKING_HOURS",
message: `${staff.user.name ?? "A staff member"} has no working hours set — they can never be booked.`,
});
}
}
for (const service of activeServices) {
const hasActiveEligibleStaff = service.staff.some((s) => s.staff.status === "ACTIVE");
if (!hasActiveEligibleStaff) {
warnings.push({
code: "SERVICE_NO_ELIGIBLE_STAFF",
message: `"${service.name}" is active but no active staff member is eligible to perform it.`,
});
}
}
if (loyaltyProgramme?.isEnabled) {
const validation = validateProgrammeConfig({
isEnabled: loyaltyProgramme.isEnabled,
stampsRequired: loyaltyProgramme.stampsRequired,
rewardType: loyaltyProgramme.rewardType,
rewardValue: loyaltyProgramme.rewardValue?.toString(),
freeServiceId: loyaltyProgramme.freeServiceId ?? undefined,
autoApplyReward: loyaltyProgramme.autoApplyReward,
requiresStaffApproval: loyaltyProgramme.requiresStaffApproval,
earnOnCancelled: loyaltyProgramme.earnOnCancelled,
earnOnNoShow: loyaltyProgramme.earnOnNoShow,
});
if (!validation.valid) {
warnings.push({
code: "LOYALTY_INCOMPLETE",
message: `The loyalty programme is enabled but incomplete: ${validation.issues[0]?.message}`,
});
}
}
const upcomingTimeOffConflicts = await findUpcomingTimeOffConflicts(prisma, businessId);
warnings.push(...upcomingTimeOffConflicts);
return warnings;
}
/** Whole-day or partial-day time off that overlaps a future, non-cancelled appointment for the same staff member — surfaced as a dashboard warning rather than blocking anything, since the time off may already have been added deliberately (e.g. via Phase 5's own conflict-acknowledge flow). */
async function findUpcomingTimeOffConflicts(prisma: PrismaClient, businessId: string): Promise<ConfigWarning[]> {
const upcomingTimeOff = await prisma.timeOff.findMany({
where: {
staff: { businessId },
status: "APPROVED",
endDate: { gte: calendarDateToUtcMidnight(new Date().toISOString().slice(0, 10)) },
},
include: { staff: { include: { user: { select: { name: true } } } } },
});
if (upcomingTimeOff.length === 0) return [];
const warnings: ConfigWarning[] = [];
for (const timeOff of upcomingTimeOff) {
const conflictCount = await prisma.appointment.count({
where: {
staffId: timeOff.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { lt: timeOff.endDate, gte: timeOff.startDate },
},
});
if (conflictCount > 0) {
warnings.push({
code: "TIME_OFF_CONFLICT",
message: `${timeOff.staff.user.name ?? "A staff member"} has ${conflictCount} upcoming appointment${conflictCount === 1 ? "" : "s"} during approved time off.`,
});
}
}
return warnings;
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { validateBookingRuleInput } from "../booking-rule.service";
import type { BookingRuleInput } from "../booking-rule.service";
const base: BookingRuleInput = {
minNoticeMinutes: 60,
maxAdvanceDays: 30,
cancellationWindowHours: 24,
rescheduleWindowHours: 24,
slotIntervalMinutes: 15,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
maxAppointmentsPerDay: null,
maxAppointmentsPerStaffPerDay: null,
customerCanChooseStaff: true,
allowGuestBooking: true,
requireApproval: false,
};
describe("validateBookingRuleInput", () => {
it("accepts the default configuration", () => {
expect(validateBookingRuleInput(base).valid).toBe(true);
});
it("rejects a negative minimum notice", () => {
const result = validateBookingRuleInput({ ...base, minNoticeMinutes: -1 });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it("rejects a maximum advance window under 1 day", () => {
const result = validateBookingRuleInput({ ...base, maxAdvanceDays: 0 });
expect(result.valid).toBe(false);
});
it("rejects a negative cancellation window", () => {
expect(validateBookingRuleInput({ ...base, cancellationWindowHours: -1 }).valid).toBe(false);
});
it("rejects a negative reschedule window", () => {
expect(validateBookingRuleInput({ ...base, rescheduleWindowHours: -1 }).valid).toBe(false);
});
it.each([4, 121])("rejects a slot interval outside 5-120 minutes (%s)", (slotIntervalMinutes) => {
expect(validateBookingRuleInput({ ...base, slotIntervalMinutes }).valid).toBe(false);
});
it.each([5, 120])("accepts slot interval boundaries (%s)", (slotIntervalMinutes) => {
expect(validateBookingRuleInput({ ...base, slotIntervalMinutes }).valid).toBe(true);
});
it("rejects a negative buffer", () => {
expect(validateBookingRuleInput({ ...base, bufferBeforeMinutes: -5 }).valid).toBe(false);
});
it("accepts null max-appointments-per-day (unlimited)", () => {
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerDay: null }).valid).toBe(true);
});
it("rejects a zero max-appointments-per-day", () => {
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerDay: 0 }).valid).toBe(false);
});
it("rejects a zero max-appointments-per-staff-per-day", () => {
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerStaffPerDay: 0 }).valid).toBe(false);
});
it("accepts a positive max-appointments-per-staff-per-day", () => {
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerStaffPerDay: 5 }).valid).toBe(true);
});
});
@@ -0,0 +1,72 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
import type { BookingRuleConfig } from "./business-rules.service";
/**
* Read/write for the BookingRule row itself — deliberately separate from
* business-rules.service.ts, which is pure evaluation logic with no Prisma
* access by design (see that file's own header). This file owns the one
* thing that one doesn't: loading and validating the actual configuration
* an owner edits, which every booking-time check then reads via
* AvailabilityService exactly as before — nothing about how rules are
* *evaluated* changes here, only how they're *set*.
*/
export type BookingRuleErrorCode = "INVALID_INPUT";
export type BookingRuleInput = BookingRuleConfig;
export function validateBookingRuleInput(input: BookingRuleInput): ValidationResult<BookingRuleErrorCode> {
if (!Number.isInteger(input.minNoticeMinutes) || input.minNoticeMinutes < 0) {
return fail({ code: "INVALID_INPUT", message: "Minimum notice can't be negative." });
}
if (!Number.isInteger(input.maxAdvanceDays) || input.maxAdvanceDays < 1) {
return fail({ code: "INVALID_INPUT", message: "Maximum advance booking must be at least 1 day." });
}
if (!Number.isInteger(input.cancellationWindowHours) || input.cancellationWindowHours < 0) {
return fail({ code: "INVALID_INPUT", message: "Cancellation window can't be negative." });
}
if (!Number.isInteger(input.rescheduleWindowHours) || input.rescheduleWindowHours < 0) {
return fail({ code: "INVALID_INPUT", message: "Reschedule window can't be negative." });
}
if (!Number.isInteger(input.slotIntervalMinutes) || input.slotIntervalMinutes < 5 || input.slotIntervalMinutes > 120) {
return fail({ code: "INVALID_INPUT", message: "Slot interval must be between 5 and 120 minutes." });
}
if (!Number.isInteger(input.bufferBeforeMinutes) || input.bufferBeforeMinutes < 0) {
return fail({ code: "INVALID_INPUT", message: "Buffer before can't be negative." });
}
if (!Number.isInteger(input.bufferAfterMinutes) || input.bufferAfterMinutes < 0) {
return fail({ code: "INVALID_INPUT", message: "Buffer after can't be negative." });
}
if (input.maxAppointmentsPerDay !== null && (!Number.isInteger(input.maxAppointmentsPerDay) || input.maxAppointmentsPerDay < 1)) {
return fail({ code: "INVALID_INPUT", message: "Max appointments per day must be at least 1, or left unlimited." });
}
if (
input.maxAppointmentsPerStaffPerDay !== null &&
(!Number.isInteger(input.maxAppointmentsPerStaffPerDay) || input.maxAppointmentsPerStaffPerDay < 1)
) {
return fail({ code: "INVALID_INPUT", message: "Max appointments per staff member per day must be at least 1, or left unlimited." });
}
return ok();
}
export async function getBookingRule(prisma: PrismaClient, businessId: string) {
return prisma.bookingRule.findUnique({ where: { businessId } });
}
export async function updateBookingRule(
prisma: PrismaClient,
params: { businessId: string; input: BookingRuleInput },
): Promise<{ success: boolean; validation: ValidationResult<BookingRuleErrorCode> }> {
const validation = validateBookingRuleInput(params.input);
if (!validation.valid) return { success: false, validation };
await prisma.bookingRule.upsert({
where: { businessId: params.businessId },
create: { businessId: params.businessId, ...params.input },
update: { ...params.input },
});
return { success: true, validation: ok() };
}
@@ -0,0 +1,244 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createStaffProfile,
deactivateStaff,
reactivateStaff,
setStaffServiceEligibility,
updateStaffAsOwner,
} from "../staff.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* Covers the owner-only staff management additions from Phase 6:
* onboarding, deactivation/last-owner protection, and service-eligibility
* conflict review. Uses createTestStaff's auto-created role (no
* MANAGE_STAFF permission) as the "ordinary barber" role, and a
* hand-created role with MANAGE_STAFF as the "owner" role — mirroring how
* countOtherActiveManagers/staffCurrentlyManagesStaff actually define "is
* an owner" (see staff.service.ts).
*/
describe("StaffService — owner actions (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
async function createOwnerRole() {
return prisma.role.create({
data: {
businessId: business.businessId,
name: "Owner",
permissions: { create: [{ permission: "MANAGE_STAFF" }] },
},
});
}
async function createBarberRole() {
return prisma.role.create({ data: { businessId: business.businessId, name: "Barber" } });
}
describe("createStaffProfile", () => {
it("creates a fully usable User + StaffProfile + eligibility in one go", async () => {
const role = await createBarberRole();
const result = await createStaffProfile(prisma, {
businessId: business.businessId,
input: {
name: "New Barber",
email: "new.barber@test.local",
roleId: role.id,
serviceIds: [business.haircutServiceId],
isBookableOnline: true,
},
});
expect(result.success).toBe(true);
if (!result.success) throw new Error("unreachable");
business.createdUserIds.push(result.staff.userId);
const eligibility = await prisma.staffService.findMany({ where: { staffId: result.staff.id } });
expect(eligibility.map((e) => e.serviceId)).toEqual([business.haircutServiceId]);
});
it("rejects an email already in use", async () => {
const role = await createBarberRole();
const { userId } = await createTestStaff(business);
const existingUser = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const result = await createStaffProfile(prisma, {
businessId: business.businessId,
input: {
name: "Duplicate",
email: existingUser.email!,
roleId: role.id,
serviceIds: [],
isBookableOnline: true,
},
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("EMAIL_ALREADY_IN_USE");
});
});
describe("last-owner protection", () => {
it("blocks deactivating the only active owner", async () => {
const ownerRole = await createOwnerRole();
const { staffId: ownerStaffId, userId } = await createTestStaff(business);
await prisma.staffProfile.update({ where: { id: ownerStaffId }, data: { roleId: ownerRole.id } });
void userId;
const result = await deactivateStaff(prisma, { staffId: ownerStaffId, businessId: business.businessId });
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("LAST_OWNER");
const stillActive = await prisma.staffProfile.findUniqueOrThrow({ where: { id: ownerStaffId } });
expect(stillActive.status).toBe("ACTIVE");
});
it("allows deactivating an owner when another active owner remains", async () => {
const ownerRole = await createOwnerRole();
const { staffId: firstOwnerId } = await createTestStaff(business);
const { staffId: secondOwnerId } = await createTestStaff(business);
await prisma.staffProfile.updateMany({
where: { id: { in: [firstOwnerId, secondOwnerId] } },
data: { roleId: ownerRole.id },
});
const result = await deactivateStaff(prisma, { staffId: firstOwnerId, businessId: business.businessId });
expect(result.success).toBe(true);
const deactivated = await prisma.staffProfile.findUniqueOrThrow({ where: { id: firstOwnerId } });
expect(deactivated.status).toBe("INACTIVE");
});
it("blocks demoting the only active owner's role away from MANAGE_STAFF", async () => {
const ownerRole = await createOwnerRole();
const barberRole = await createBarberRole();
const { staffId: ownerStaffId } = await createTestStaff(business);
await prisma.staffProfile.update({ where: { id: ownerStaffId }, data: { roleId: ownerRole.id } });
const result = await updateStaffAsOwner(prisma, {
staffId: ownerStaffId,
businessId: business.businessId,
input: { roleId: barberRole.id },
});
expect(result.success).toBe(false);
if (result.success) throw new Error("unreachable");
expect(result.validation.issues[0]?.code).toBe("LAST_OWNER");
const stillOwner = await prisma.staffProfile.findUniqueOrThrow({ where: { id: ownerStaffId } });
expect(stillOwner.roleId).toBe(ownerRole.id);
});
it("reactivating a deactivated staff member restores ACTIVE status", async () => {
const { staffId } = await createTestStaff(business);
await prisma.staffProfile.update({ where: { id: staffId }, data: { status: "INACTIVE" } });
const result = await reactivateStaff(prisma, { staffId, businessId: business.businessId });
expect(result.success).toBe(true);
const staff = await prisma.staffProfile.findUniqueOrThrow({ where: { id: staffId } });
expect(staff.status).toBe("ACTIVE");
});
});
describe("deactivation appointment conflicts", () => {
it("blocks deactivating a staff member with a future appointment, and Save Anyway bypasses it without cancelling it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const blocked = await deactivateStaff(prisma, { staffId, businessId: business.businessId });
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
const acknowledged = await deactivateStaff(prisma, {
staffId,
businessId: business.businessId,
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
const stillThere = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(stillThere.status).toBe("CONFIRMED");
expect(stillThere.staffId).toBe(staffId);
});
});
describe("setStaffServiceEligibility", () => {
it("blocks removing a service that has a future appointment booked, and Save Anyway bypasses it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
// Existing fixture staff is eligible for both haircut and beard trim
// (see createTestStaff) — removing haircut should conflict.
const blocked = await setStaffServiceEligibility(prisma, {
staffId,
businessId: business.businessId,
serviceIds: [business.beardTrimServiceId],
});
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.length).toBeGreaterThan(0);
const acknowledged = await setStaffServiceEligibility(prisma, {
staffId,
businessId: business.businessId,
serviceIds: [business.beardTrimServiceId],
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
const eligibility = await prisma.staffService.findMany({ where: { staffId } });
expect(eligibility.map((e) => e.serviceId)).toEqual([business.beardTrimServiceId]);
});
it("adding a service never triggers a conflict", async () => {
const { staffId } = await createTestStaff(business);
const result = await setStaffServiceEligibility(prisma, {
staffId,
businessId: business.businessId,
serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
});
expect(result.success).toBe(true);
});
});
});
@@ -4,6 +4,7 @@ import {
StaffPermissionDeniedError, StaffPermissionDeniedError,
assertOwnsResource, assertOwnsResource,
validateBreakInput, validateBreakInput,
validateCreateStaffInput,
validateTimeOffInput, validateTimeOffInput,
validateWorkingHoursInput, validateWorkingHoursInput,
} from "../staff.service"; } from "../staff.service";
@@ -135,3 +136,38 @@ describe("validateTimeOffInput", () => {
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
}); });
}); });
describe("validateCreateStaffInput", () => {
const base = {
name: "Owen Clarke",
email: "owen.clarke@blakeheritage.co.uk",
roleId: "role-barber",
serviceIds: [] as string[],
isBookableOnline: true,
};
it("accepts a valid new-staff input", () => {
expect(validateCreateStaffInput(base).valid).toBe(true);
});
it("rejects an empty name", () => {
const result = validateCreateStaffInput({ ...base, name: " " });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
it.each(["not-an-email", "missing-domain@", "@missing-local.com", ""])(
"rejects an invalid email %s",
(email) => {
const result = validateCreateStaffInput({ ...base, email });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
},
);
it("rejects a missing role", () => {
const result = validateCreateStaffInput({ ...base, roleId: "" });
expect(result.valid).toBe(false);
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
});
});
@@ -0,0 +1,103 @@
import { randomBytes } from "node:crypto";
import bcrypt from "bcryptjs";
import type { PrismaClient } from "@/generated/prisma/client";
/**
* The "invite a new staff member" flow, deliberately split out of
* StaffService (see docs/phase-6-summary.md, "Domain services" — the
* brief's own instruction not to mix invitation delivery into StaffService).
* StaffService.createStaffProfile makes the User/StaffProfile usable
* immediately; this file only tracks the separate "has this person set
* their own password yet" signal via StaffInvitation.acceptedAt.
*
* No real email delivery this phase — `token` is a plain opaque value
* (crypto-random, not HMAC-signed like the booking-draft cookie, because
* it's validated against a database row rather than needing to be
* self-contained) surfaced to the owner as a copy-link
* (`/invite/accept?token=...`) to send however they like. This is a
* documented development-safe shortcut, not a production email system.
*/
const INVITATION_TTL_DAYS = 7;
export type StaffInvitationErrorCode = "INVALID_TOKEN" | "EXPIRED" | "ALREADY_ACCEPTED" | "INVALID_INPUT";
function generateToken(): string {
return randomBytes(32).toString("base64url");
}
export async function createInvitation(
prisma: PrismaClient,
params: { businessId: string; staffId: string; email: string; invitedByUserId?: string },
) {
return prisma.staffInvitation.create({
data: {
businessId: params.businessId,
staffId: params.staffId,
email: params.email,
token: generateToken(),
invitedByUserId: params.invitedByUserId,
expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000),
},
});
}
/** An expired or already-viewed invitation gets a fresh token/expiry rather than a second row — one StaffInvitation per StaffProfile (see the schema's `staffId @unique`). */
export async function regenerateInvitation(
prisma: PrismaClient,
params: { staffId: string; businessId: string },
) {
const existing = await prisma.staffInvitation.findFirst({
where: { staffId: params.staffId, businessId: params.businessId },
});
if (!existing) return null;
return prisma.staffInvitation.update({
where: { id: existing.id },
data: { token: generateToken(), expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000), acceptedAt: null },
});
}
export async function getInvitationByToken(prisma: PrismaClient, token: string) {
return prisma.staffInvitation.findUnique({
where: { token },
include: {
staff: { include: { user: { select: { id: true, name: true, email: true } }, business: { select: { name: true } } } },
},
});
}
export type InvitationCheck =
| { valid: true; invitation: NonNullable<Awaited<ReturnType<typeof getInvitationByToken>>> }
| { valid: false; reason: StaffInvitationErrorCode };
export async function validateInvitationToken(prisma: PrismaClient, token: string): Promise<InvitationCheck> {
const invitation = await getInvitationByToken(prisma, token);
if (!invitation) return { valid: false, reason: "INVALID_TOKEN" };
if (invitation.acceptedAt) return { valid: false, reason: "ALREADY_ACCEPTED" };
if (invitation.expiresAt < new Date()) return { valid: false, reason: "EXPIRED" };
return { valid: true, invitation };
}
export async function acceptInvitation(
prisma: PrismaClient,
params: { token: string; password: string },
): Promise<{ success: boolean; reason?: StaffInvitationErrorCode }> {
if (params.password.length < 8) return { success: false, reason: "INVALID_INPUT" };
const check = await validateInvitationToken(prisma, params.token);
if (!check.valid) return { success: false, reason: check.reason };
const passwordHash = await bcrypt.hash(params.password, 12);
await prisma.$transaction([
prisma.user.update({
where: { id: check.invitation.staff.userId },
data: { passwordHash, emailVerified: new Date() },
}),
prisma.staffInvitation.update({
where: { id: check.invitation.id },
data: { acceptedAt: new Date() },
}),
]);
return { success: true };
}
+361 -1
View File
@@ -31,7 +31,10 @@ export type StaffServiceErrorCode =
| "BREAK_NOT_FOUND" | "BREAK_NOT_FOUND"
| "TIME_OFF_NOT_FOUND" | "TIME_OFF_NOT_FOUND"
| "APPOINTMENT_CONFLICTS" | "APPOINTMENT_CONFLICTS"
| "PERMISSION_DENIED"; | "PERMISSION_DENIED"
| "EMAIL_ALREADY_IN_USE"
| "ROLE_NOT_FOUND"
| "LAST_OWNER";
export class StaffPermissionDeniedError extends Error { export class StaffPermissionDeniedError extends Error {
constructor(message = "You don't have permission to manage this staff member's schedule.") { constructor(message = "You don't have permission to manage this staff member's schedule.") {
@@ -625,3 +628,360 @@ export async function setNotificationPreferences(
); );
return { success: true }; return { success: true };
} }
// ── Owner: team management ─────────────────────────────────────────────
// Everything below acts on staff *other than* the caller — the owner's own
// business-membership must already be verified by the caller (see
// src/features/owner/authorization.ts); every function here additionally
// re-scopes by businessId itself rather than trusting the caller alone.
export async function listStaffForBusiness(
prisma: PrismaClient,
params: { businessId: string; q?: string; includeInactive?: boolean },
) {
return prisma.staffProfile.findMany({
where: {
businessId: params.businessId,
...(params.includeInactive ? {} : { status: { not: "INACTIVE" } }),
...(params.q
? {
user: {
OR: [
{ name: { contains: params.q, mode: "insensitive" } },
{ email: { contains: params.q, mode: "insensitive" } },
],
},
}
: {}),
},
include: {
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
role: { select: { id: true, name: true } },
invitation: true,
},
orderBy: { createdAt: "asc" },
});
}
export async function getStaffDetailById(prisma: PrismaClient, params: { staffId: string; businessId: string }) {
return prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
include: {
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
role: { include: { permissions: true } },
services: { include: { service: true } },
invitation: true,
},
});
}
export async function listRolesForBusiness(prisma: PrismaClient, businessId: string) {
return prisma.role.findMany({
where: { businessId },
include: { permissions: true },
orderBy: { name: "asc" },
});
}
/** A role holding MANAGE_STAFF is this app's definition of "an owner" — see docs/phase-6-summary.md, "Roles and permissions". */
async function countOtherActiveManagers(
prisma: PrismaClient,
params: { businessId: string; excludeStaffId: string },
) {
return prisma.staffProfile.count({
where: {
businessId: params.businessId,
status: "ACTIVE",
id: { not: params.excludeStaffId },
role: { permissions: { some: { permission: "MANAGE_STAFF" } } },
},
});
}
async function staffCurrentlyManagesStaff(prisma: PrismaClient, staffId: string): Promise<boolean> {
const staff = await prisma.staffProfile.findUnique({
where: { id: staffId },
select: { status: true, role: { select: { permissions: { select: { permission: true } } } } },
});
return (
!!staff &&
staff.status === "ACTIVE" &&
staff.role.permissions.some((p) => p.permission === "MANAGE_STAFF")
);
}
export interface CreateStaffInput {
name: string;
email: string;
phone?: string;
image?: string;
bio?: string;
roleId: string;
serviceIds: string[];
isBookableOnline: boolean;
}
export function validateCreateStaffInput(input: CreateStaffInput): ValidationResult<StaffServiceErrorCode> {
if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Name is required." });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
return fail({ code: "INVALID_INPUT", message: "Enter a valid email address." });
}
if (!input.roleId) return fail({ code: "INVALID_INPUT", message: "Choose a role." });
return ok();
}
/** Creates the User + StaffProfile + initial service eligibility immediately — the profile is fully usable right away. Working hours default to "off every day" until the owner (or the staff member, once they've accepted) sets them; StaffInvitationService is what tracks acceptance, deliberately kept separate — see that file. */
export async function createStaffProfile(
prisma: PrismaClient,
params: { businessId: string; input: CreateStaffInput },
) {
const validation = validateCreateStaffInput(params.input);
if (!validation.valid) return { success: false as const, validation };
const [role, existingUser] = await Promise.all([
prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } }),
prisma.user.findUnique({ where: { email: params.input.email.toLowerCase() } }),
]);
if (!role) {
return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
}
if (existingUser) {
return {
success: false as const,
validation: fail({ code: "EMAIL_ALREADY_IN_USE", message: "That email is already in use." }),
};
}
const user = await prisma.user.create({
data: {
name: params.input.name.trim(),
email: params.input.email.toLowerCase(),
phone: params.input.phone || null,
image: params.input.image || null,
staffProfile: {
create: {
businessId: params.businessId,
roleId: params.input.roleId,
bio: params.input.bio?.trim() || null,
isBookableOnline: params.input.isBookableOnline,
services: { create: params.input.serviceIds.map((serviceId) => ({ serviceId })) },
},
},
},
include: { staffProfile: true },
});
return { success: true as const, staff: user.staffProfile!, validation: ok() };
}
export interface UpdateStaffAsOwnerInput {
name?: string;
phone?: string;
image?: string;
bio?: string;
isBookableOnline?: boolean;
roleId?: string;
}
export async function updateStaffAsOwner(
prisma: PrismaClient,
params: { staffId: string; businessId: string; input: UpdateStaffAsOwnerInput },
) {
const existing = await prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
});
if (!existing) {
return { success: false as const, validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }) };
}
if (params.input.roleId && params.input.roleId !== existing.roleId) {
const role = await prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } });
if (!role) {
return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
}
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
const stillManagesAfter = !!(await prisma.rolePermission.findFirst({
where: { roleId: role.id, permission: "MANAGE_STAFF" },
}));
if (wasManager && !stillManagesAfter) {
const otherManagers = await countOtherActiveManagers(prisma, {
businessId: params.businessId,
excludeStaffId: params.staffId,
});
if (otherManagers === 0) {
return {
success: false as const,
validation: fail({
code: "LAST_OWNER",
message: "This is the only active owner — assign another owner first.",
}),
};
}
}
}
await prisma.$transaction([
prisma.user.update({
where: { id: existing.userId },
data: {
...(params.input.name !== undefined ? { name: params.input.name } : {}),
...(params.input.phone !== undefined ? { phone: params.input.phone || null } : {}),
...(params.input.image !== undefined ? { image: params.input.image || null } : {}),
},
}),
prisma.staffProfile.update({
where: { id: params.staffId },
data: {
...(params.input.bio !== undefined ? { bio: params.input.bio || null } : {}),
...(params.input.isBookableOnline !== undefined
? { isBookableOnline: params.input.isBookableOnline }
: {}),
...(params.input.roleId !== undefined ? { roleId: params.input.roleId } : {}),
},
}),
]);
return { success: true as const, validation: ok() };
}
/** Future, non-cancelled appointments still assigned to this staff member — shown for acknowledgement before deactivating, never auto-resolved. Deactivating does not touch these rows; it only removes the staff member from future booking/availability. */
export async function findStaffDeactivationConflicts(
prisma: PrismaClient,
params: { staffId: string },
) {
return prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
},
include: { customer: true, services: { include: { service: true } } },
orderBy: { startAt: "asc" },
});
}
export async function deactivateStaff(
prisma: PrismaClient,
params: { staffId: string; businessId: string; acknowledgeConflicts?: boolean },
) {
const existing = await prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }),
conflicts: [],
};
}
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
if (wasManager) {
const otherManagers = await countOtherActiveManagers(prisma, {
businessId: params.businessId,
excludeStaffId: params.staffId,
});
if (otherManagers === 0) {
return {
success: false as const,
validation: fail({
code: "LAST_OWNER",
message: "This is the only active owner — assign another owner first.",
}),
conflicts: [],
};
}
}
if (!params.acknowledgeConflicts) {
const conflicts = await findStaffDeactivationConflicts(prisma, { staffId: params.staffId });
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This staff member still has upcoming appointments.",
}),
};
}
}
await prisma.staffProfile.update({ where: { id: params.staffId }, data: { status: "INACTIVE" } });
return { success: true as const, conflicts: [], validation: ok() };
}
export async function reactivateStaff(
prisma: PrismaClient,
params: { staffId: string; businessId: string },
): Promise<{ success: boolean }> {
const result = await prisma.staffProfile.updateMany({
where: { id: params.staffId, businessId: params.businessId },
data: { status: "ACTIVE" },
});
return { success: result.count > 0 };
}
/** Future, non-cancelled appointments this staff member has that book a service being *removed* from their eligible list — the only ones that matter, since keeping or adding eligibility can never orphan an existing booking. */
export async function findServiceEligibilityConflicts(
prisma: PrismaClient,
params: { staffId: string; removedServiceIds: string[] },
) {
if (params.removedServiceIds.length === 0) return [];
return prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: new Date() },
services: { some: { serviceId: { in: params.removedServiceIds } } },
},
include: { customer: true, services: { include: { service: true } } },
orderBy: { startAt: "asc" },
});
}
export async function setStaffServiceEligibility(
prisma: PrismaClient,
params: { staffId: string; businessId: string; serviceIds: string[]; acknowledgeConflicts?: boolean },
) {
const existing = await prisma.staffProfile.findFirst({
where: { id: params.staffId, businessId: params.businessId },
include: { services: true },
});
if (!existing) {
return {
success: false as const,
validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }),
conflicts: [],
};
}
const currentServiceIds = existing.services.map((s) => s.serviceId);
const removedServiceIds = currentServiceIds.filter((id) => !params.serviceIds.includes(id));
if (!params.acknowledgeConflicts) {
const conflicts = await findServiceEligibilityConflicts(prisma, {
staffId: params.staffId,
removedServiceIds,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "This staff member has upcoming appointments for a service you're removing.",
}),
};
}
}
await prisma.$transaction([
prisma.staffService.deleteMany({ where: { staffId: params.staffId } }),
prisma.staffService.createMany({
data: params.serviceIds.map((serviceId) => ({ staffId: params.staffId, serviceId })),
}),
]);
return { success: true as const, conflicts: [], validation: ok() };
}