Compare commits

...

20 Commits

Author SHA1 Message Date
Flea Flicker 0361b84bd5 feat(GRO-1179): add PetProfileCard component with medical alert severity badges
CI / Test (pull_request) Failing after 30s
CI / Lint & Typecheck (pull_request) Failing after 38s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Create PetProfileCard fetching from GET /api/pets/:id/profile-summary
- Displays: pet photo/name/breed/age/weight, coat type badge, temperament
  score (1-5 dots) + flag badges, medical alerts (severity-colored),
  preferred cuts, recent visits, next appointment
- Loading skeleton and error/empty states
- Integrate into Appointments booking form after pet selection
- Integrate into ClientDetailPage as expandable card per pet
- Export PetProfileSummary + NextAppointment types in @groombook/types
- Add PetProfileCard tests covering full data, empty data, loading, error

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-25 23:49:23 +00:00
Flea Flicker 034f4ab295 docs(GRO-1470): add UAT test cases for pet profile API persistence
CI / Test (pull_request) Successful in 13s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 20s
Add §5.23 covering:
- API persistence (page reload verification)
- Save error state (form stays open on failure)
- Saving indicator (spinner while in-flight)

Updated UAT_PLAYBOOK.md §5.23

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:32:35 +00:00
Flea Flicker f958dbdb4f Persist pet profile changes via PATCH /api/portal/pets/{petId}
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Build & Push Docker Image (pull_request) Successful in 34s
- handlePetSave is now async; calls PATCH before updating local state
- API response used as source of truth for local state update
- Error state shown on API failure; edit form NOT cleared on failure
- Loading/saving indicator in PetForm while API call in flight

Refs: GRO-1470
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:17:12 +00:00
Flea Flicker 837b5f6d8a docs(GRO-1173): add UAT test cases for buffer rules, service default buffer, pet size/coat
CI / Test (pull_request) Successful in 16s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 17s
- §5.7: add TC-WEB-5.7.5 through TC-WEB-5.7.7 for pet size/coat in admin UI
- §5.8.1 (new): add Buffer Rules Management test cases
  - TC-WEB-5.8.2 through TC-WEB-5.8.7: create/edit/delete buffer rules, size/coat filtering, empty state
- §5.9: add TC-WEB-5.9.4 and TC-WEB-5.9.5 for service default buffer minutes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 14:52:42 +00:00
Flea Flicker 889e1e26ae feat(GRO-1173): add sizeCategory and coatType dropdowns to admin pet form
CI / Test (pull_request) Successful in 16s
CI / Lint & Typecheck (pull_request) Successful in 20s
CI / Build & Push Docker Image (pull_request) Successful in 33s
- PetForm interface: add sizeCategory and coatType fields
- EMPTY_PET: initialise new fields as empty strings
- openEditPet: pre-populate from pet.petSizeCategory and pet.coatType
- submitPet body: include petSizeCategory and coatType in POST/PATCH
- Pet form UI: add Size Category and Coat Type dropdowns after Breed field
  - Size: Small / Medium / Large / X-Large (maps to enum values)
  - Coat: Smooth / Double / Curly / Wire / Long / Hairless (maps to CoatType union)
  - Both optional — blank "Not set" option matches API optional semantics

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 14:44:55 +00:00
Flea Flicker ef6d9d5ab5 fix(CI): use Gitea registry for Docker push
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Failing after 37s
Switch from ghcr.io (GitHub Container Registry) to git.farh.net
Gitea Container Registry. The Gitea Actions runner does not have
access to GitHub's GITHUB_TOKEN for ghcr.io authentication.

Based on fix/ci-registry-auth branch pattern (a582bd0).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 12:29:29 +00:00
Flea Flicker 5fec0c938a Fix: render error state in BufferRulesSection (fixes lint error)
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Build & Push Docker Image (pull_request) Failing after 8s
The 'error' useState was declared but never read — only setError was called.
Now renders the error message as a red text node when the fetch fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:38:03 +00:00
Flea Flicker 1820f82cfb docs(GRO-1173): add UAT test cases for buffer rules, service buffer, and pet size/coat
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Failing after 17s
CI / Build & Push Docker Image (pull_request) Has been skipped
Sections 5.20, 5.21, and 5.22 added to UAT_PLAYBOOK.md:
- TC-WEB-5.20.x: Buffer rules CRUD, validation, empty state (14 cases)
- TC-WEB-5.21.x: Service default buffer field in table and form (7 cases)
- TC-WEB-5.22.x: Pet size category and coat type in portal PetForm (7 cases)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 11:16:34 +00:00
Flea Flicker ec17f1e885 feat(GRO-1173): apply buffer rules UI changes to extracted groombook/web repo
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Failing after 18s
CI / Build & Push Docker Image (pull_request) Has been skipped
This commit ports the GRO-1173 admin UI changes from the app monorepo
into the extracted groombook/web repo, using the correct source paths
(src/ instead of apps/web/src/):

- New BufferRulesSection component (full CRUD UI for /api/buffer-rules)
- Default Buffer (minutes) field added to service create/edit form
- Size Category and Coat Type dropdowns added to PetForm (portal)
- @groombook/types Service interface extended with defaultBufferMinutes
- BufferRulesSection embedded in Settings page

The PetForm already had coatType — this commit adds petSizeCategory
and renders both fields with proper dropdown selectors.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 11:12:45 +00:00
Chris Farhood f414d2589f fix(GRO-1366): add non-null assertion to removeButtons[0]
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Failing after 8s
Fix TypeScript error on line 114: HTMLElement | undefined is not
assignable to Element. Added ! assertion since length guard already
excludes the empty-array case.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 14:22:02 +00:00
Chris Farhood d1f8d27d1c fix: remove unused X import from lucide-react
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Build & Push Docker Image (pull_request) Failing after 7s
Resolves ESLint error: 'X' is defined but never used
GRO-1347

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 10:55:24 +00:00
Chris Farhood 6363465069 Fix typecheck: add null guard for removeButtons[0] in PetForm test
CI / Test (push) Successful in 17s
CI / Lint & Typecheck (push) Failing after 21s
CI / Build & Push Docker Image (push) Has been skipped
CI / Lint & Typecheck (pull_request) Failing after 17s
CI / Test (pull_request) Successful in 1m1s
CI / Build & Push Docker Image (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 05:16:14 +00:00
Chris Farhood 42d1c5cf34 Fix QA review 2719: optional coatType/petSizeCategory, CoatType union, null guards
CI / Test (pull_request) Successful in 16s
CI / Lint & Typecheck (pull_request) Failing after 20s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Make coatType and petSizeCategory optional on Pet (?:) — they may not be set
- Remove "single" and "short" from COAT_TYPES (not in CoatType union)
- Use { name: "Add" } instead of /add/i to target the + button specifically
- Add optional chaining to puppyCutSpans[0]?.closest() (noUncheckedIndexedAccess)
- Add optional chaining to petsData[0]?.id ?? "" in PetProfiles

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 05:01:36 +00:00
Chris Farhood 2ec1b6a14d Fix QA re-review: add missing types, aria-label, and temperament text
CI / Test (pull_request) Failing after 16s
CI / Lint & Typecheck (pull_request) Failing after 18s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Add MedicalAlert, AlertSeverity, CoatType, preferredCuts, medicalAlerts,
  temperamentScore, temperamentFlags to @groombook/types Pet interface
- Add aria-label="Add" to the preferred cuts + button
- Fix temperament text expectation from "(/4/5)" to "(4/5)"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 04:53:52 +00:00
Chris Farhood 6132148cb5 Fix QA feedback: type imports, query methods, implicit any, null guards, accessibility
CI / Test (pull_request) Failing after 45s
CI / Lint & Typecheck (pull_request) Failing after 1m42s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Import Pet/MedicalAlert/CoatType/AlertSeverity from @groombook/types (workspace dep)
- Replace getByPlaceholder with getByPlaceholderText in test file
- Add explicit MedicalAlert type to destructured alert param in PetForm
- Add null guards for HTMLElement | undefined in test lines 79/111
- Add htmlFor=coat-type label association for accessible combobox

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 04:33:19 +00:00
Chris Farhood c047e277b9 docs(GRO-1174): add §5.19 booking wizard test cases
CI / Test (pull_request) Failing after 15s
CI / Lint & Typecheck (pull_request) Failing after 16s
CI / Build & Push Docker Image (pull_request) Has been skipped
Updated UAT_PLAYBOOK.md §5.19 — booking wizard pet size/coat test cases.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 03:13:25 +00:00
Chris Farhood 29fa0bd02b feat(GRO-1174): add pet size/coat dropdowns to booking wizard
- Add Pet Size dropdown (Small, Medium, Large, X-Large) after breed field
- Add Coat Type dropdown (Smooth, Double, Curly, Wire, Long, Hairless)
- Pass petSizeCategory + petCoatType as query params to availability endpoint
- Include petSizeCategory + petCoatType in POST /appointments body
- Show "appointment" duration label on confirm (service duration only)
- Display pet size/coat on confirmation card when provided
- Pre-fill from URL params
- Reset form resets all new fields

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 02:44:14 +00:00
Chris Farhood b8eb39e15c GRO-1178: add UAT test cases for enhanced pet profile fields
- Added TC-WEB-5.18.1 through TC-WEB-5.18.13 covering:
  coat type display, preferred cuts, temperament score/flags (read-only),
  medical alert cards, severity badges, form validation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 00:30:01 +00:00
Chris Farhood c7f0635fff GRO-1178: client-facing enhanced pet profile editor
- PetForm: coat type dropdown, temperament display (read-only),
  medical alerts editor (add/remove/severity), preferred cuts tag input
- PetProfiles: Medical tab shows severity badges, Grooming tab shows
  coat type + preferred cuts, Basic Info tab shows temperament score/flags
- PetForm.test: component tests for all new interactions
- Shared types updated: MedicalAlert, CoatType, AlertSeverity added

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 00:23:16 +00:00
groombook-engineer[bot] 90f6a30b74 docs(GRO-1289): add UAT_PLAYBOOK.md with auth base URL test cases (#5)
* docs(GRO-1289): add UAT_PLAYBOOK.md with auth base URL test cases

Add UAT_PLAYBOOK.md covering VITE_API_URL auth resolution:
- TC-AUTH-4.1.x: Tests for when VITE_API_URL is set
- TC-AUTH-4.2.x: Tests for when VITE_API_URL is unset (window.location.origin fallback)
- TC-AUTH-4.3.x: Session persistence tests

Updated UAT_PLAYBOOK.md §4 — auth base URL resolution test cases.

GRO-1289

* docs(GRO-1289): restore full UAT_PLAYBOOK with auth base URL test cases

- Restored Pre-conditions section (§3)
- Restored original §5.1 Authentication UI test cases
- Inserted new auth base URL resolution test cases (§5.2–§5.4):
  - TC-AUTH-5.2.x: VITE_API_URL set scenarios
  - TC-AUTH-5.3.x: VITE_API_URL unset fallback scenarios
  - TC-AUTH-5.4.x: Session persistence scenarios
- Restored all other feature test sections (§5.5–§5.17)
- Restored broader Update Policy (§7)

Updated UAT_PLAYBOOK.md §5.2–§5.4 — auth base URL resolution test cases

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-14 21:10:35 +00:00
16 changed files with 1725 additions and 248 deletions
+8 -12
View File
@@ -62,10 +62,6 @@ jobs:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: [lint-typecheck, test]
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
@@ -83,12 +79,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
registry: git.farh.net
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push Web image
uses: docker/build-push-action@v6
@@ -97,7 +93,7 @@ jobs:
file: Dockerfile
push: true
tags: |
ghcr.io/groombook/web:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/web:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
git.farh.net/groombook/web:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:web
cache-to: type=registry,ref=git.farh.net/groombook/cache:web,mode=max
+209 -64
View File
@@ -20,125 +20,270 @@ GroomBook Web is the React 19 PWA frontend for the GroomBook pet grooming manage
- GroomBook API service is running and healthy
- Required test data exists (clients, pets, appointments, services, staff)
## 4. Test Cases
## 4. Auth Base URL Resolution
### 4.1 Authentication UI
The auth client resolves its API base URL based on the `VITE_API_URL` environment variable:
- **When `VITE_API_URL` is set:** Uses the configured URL as the auth base URL.
- **When `VITE_API_URL` is unset:** Falls back to `window.location.origin`.
This allows the app to work correctly in both:
- **Dev/PR deployments:** Where `VITE_API_URL` is explicitly set to the deployed API endpoint.
- **Local development:** Where `VITE_API_URL` is not set, using the same origin as the web app.
### Auth Client Configuration (src/lib/auth-client.ts)
```typescript
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? "",
});
export const { signIn, signOut, useSession, changePassword } = authClient;
```
## 5. Test Cases
### 5.1 Authentication UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.1.1 | Login page loads | Navigate to UAT URL | Login form is displayed with OIDC provider button(s) |
| TC-WEB-4.1.2 | OIDC redirect | Click OIDC login button | Redirected to OIDC provider, then back to app with session established |
| TC-WEB-4.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
| TC-WEB-4.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
| TC-WEB-5.1.1 | Login page loads | Navigate to UAT URL | Login form is displayed with OIDC provider button(s) |
| TC-WEB-5.1.2 | OIDC redirect | Click OIDC login button | Redirected to OIDC provider, then back to app with session established |
| TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
| TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
### 4.2 Dashboard
### 5.2 Authentication — VITE_API_URL Set
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.2.1 | Dashboard loads after login | Complete authentication | Dashboard page loads without errors |
| TC-WEB-4.2.2 | Key metrics visible | View dashboard | Revenue, appointments, clients, and other key metrics displayed |
| TC-WEB-4.2.3 | No blank state | On fresh login | Dashboard shows meaningful data, not empty/blank state |
| TC-AUTH-5.2.1 | Auth client uses configured API URL | Configure `VITE_API_URL=https://api.example.com`, load app | Auth client sends requests to `https://api.example.com` |
| TC-AUTH-5.2.2 | Sign-in flow with configured API | Sign in when `VITE_API_URL` is set | Auth requests go to configured URL |
| TC-AUTH-5.2.3 | Sign-out flow with configured API | Sign out when `VITE_API_URL` is set | Auth requests go to configured URL |
### 4.3 Client Management UI
### 5.3 Authentication — VITE_API_URL Unset (Fallback)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.3.1 | Client list loads | Navigate to Clients section | List of clients is displayed |
| TC-WEB-4.3.2 | Create client | Click "New Client", fill form, submit | Client created successfully, appears in list |
| TC-WEB-4.3.3 | Edit client | Click on client, modify details, save | Client updated successfully |
| TC-WEB-4.3.4 | Search clients | Enter search term in search box | List filters to matching clients |
| TC-WEB-4.3.5 | Archive client | Click archive on client record | Client marked as archived, removed from active list |
| TC-AUTH-5.3.1 | Auth client falls back to window.location.origin | Do not set `VITE_API_URL`, load app | Auth client uses `window.location.origin` as base URL |
| TC-AUTH-5.3.2 | Sign-in on localhost | Load app without `VITE_API_URL` on localhost:3000 | Auth client uses `http://localhost:3000` as base URL |
| TC-AUTH-5.3.3 | Sign-in on dev environment | Load app without `VITE_API_URL` on `https://dev.groombook.dev` | Auth client uses `https://dev.groombook.dev` as base URL |
### 4.4 Pet Management UI
### 5.4 Session Persistence
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.4.1 | Pet profiles visible | Open client details | All pets for client displayed with basic info |
| TC-WEB-4.4.2 | Add pet | Click "Add Pet", fill form, submit | Pet created and linked to client |
| TC-WEB-4.4.3 | Edit pet details | Click on pet, modify details, save | Pet updated successfully |
| TC-WEB-4.4.4 | Grooming history view | View pet profile | Past appointments/grooming sessions displayed |
| TC-AUTH-5.4.1 | Session persists across page reload | Sign in, reload page | Session remains active |
| TC-AUTH-5.4.2 | Session clears on sign-out | Sign in, sign out | User is logged out, redirected to login |
### 4.5 Appointment Scheduling UI
### 5.5 Dashboard
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.5.1 | Calendar view loads | Navigate to Appointments | Calendar view displays appointments |
| TC-WEB-4.5.2 | Create booking | Click "New Appointment", fill details, submit | Appointment created and appears on calendar |
| TC-WEB-4.5.3 | Modify appointment | Click on appointment, change details, save | Appointment updated successfully |
| TC-WEB-4.5.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
| TC-WEB-4.5.5 | Appointment groups | View grouped appointments | Related appointments display as group |
| TC-WEB-5.5.1 | Dashboard loads after login | Complete authentication | Dashboard page loads without errors |
| TC-WEB-5.5.2 | Key metrics visible | View dashboard | Revenue, appointments, clients, and other key metrics displayed |
| TC-WEB-5.5.3 | No blank state | On fresh login | Dashboard shows meaningful data, not empty/blank state |
### 4.6 Service Management UI
### 5.6 Client Management UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.6.1 | Service catalog loads | Navigate to Services | List of available services displayed |
| TC-WEB-4.6.2 | Create service | Click "New Service", fill form, submit | Service created successfully |
| TC-WEB-4.6.3 | Edit service | Click on service, modify details, save | Service updated successfully |
| TC-WEB-5.6.1 | Client list loads | Navigate to Clients section | List of clients is displayed |
| TC-WEB-5.6.2 | Create client | Click "New Client", fill form, submit | Client created successfully, appears in list |
| TC-WEB-5.6.3 | Edit client | Click on client, modify details, save | Client updated successfully |
| TC-WEB-5.6.4 | Search clients | Enter search term in search box | List filters to matching clients |
| TC-WEB-5.6.5 | Archive client | Click archive on client record | Client marked as archived, removed from active list |
### 4.7 Staff Management UI
### 5.7 Pet Management UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.7.1 | Staff list loads | Navigate to Staff | List of staff members displayed |
| TC-WEB-4.7.2 | Role display | View staff member | Staff role/permissions clearly visible |
| TC-WEB-5.7.1 | Pet profiles visible | Open client details | All pets for client displayed with basic info |
| TC-WEB-5.7.2 | Add pet | Click "Add Pet", fill form, submit | Pet created and linked to client |
| TC-WEB-5.7.3 | Edit pet details | Click on pet, modify details, save | Pet updated successfully |
| TC-WEB-5.7.4 | Grooming history view | View pet profile | Past appointments/grooming sessions displayed |
| TC-WEB-5.7.5 | Add pet with size/coat | Create pet with Size Category and Coat Type filled | Size and coat type persisted, visible on pet profile |
| TC-WEB-5.7.6 | Edit pet size/coat | Edit existing pet, change size/coat dropdowns | Updated values saved to pet record |
| TC-WEB-5.7.7 | Size/coat optional | Create pet without selecting size or coat | Pet created successfully, fields remain unset |
### 4.8 Invoicing & Payments UI
### 5.8.1 Buffer Rules Management UI (GRO-1173)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.8.1 | Invoice list loads | Navigate to Invoices | List of invoices displayed with status |
| TC-WEB-4.8.2 | Payment flow | Click "Pay" on unpaid invoice, complete payment | Payment processed, invoice marked as paid |
| TC-WEB-4.8.3 | Receipts view | View paid invoice | Receipt/payment details displayed |
| TC-WEB-5.8.2 | Buffer rules section visible | Navigate to Settings | "Buffer Rules" section shown with description |
| TC-WEB-5.8.3 | Create buffer rule | Click "+ Add Rule", select service and buffer minutes, submit | Rule appears in list, matches service/size/coat |
| TC-WEB-5.8.4 | Edit buffer minutes inline | Click Edit on a rule, change minutes, click Save | New buffer value reflected in list |
| TC-WEB-5.8.5 | Delete buffer rule | Click Delete, confirm | Rule removed from list |
| TC-WEB-5.8.6 | Create rule with size/coat | Create rule with Size Category or Coat Type specified | Rule shows size/coat tags in list |
| TC-WEB-5.8.7 | Empty state | Navigate to Settings with no rules | "No buffer rules configured yet" message shown |
### 4.9 Customer Portal UI
### 5.8 Appointment Scheduling UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.9.1 | Client-facing view | Log in as client persona | Customer portal UI displayed |
| TC-WEB-4.9.2 | Appointment list | View client portal appointments | List of client's appointments visible |
| TC-WEB-4.9.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
| TC-WEB-4.9.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
| TC-WEB-5.8.1 | Calendar view loads | Navigate to Appointments | Calendar view displays appointments |
| TC-WEB-5.8.2 | Create booking | Click "New Appointment", fill details, submit | Appointment created and appears on calendar |
| TC-WEB-5.8.3 | Modify appointment | Click on appointment, change details, save | Appointment updated successfully |
| TC-WEB-5.8.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
| TC-WEB-5.8.5 | Appointment groups | View grouped appointments | Related appointments display as group |
### 4.10 Reports UI
### 5.9 Service Management UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.10.1 | Revenue charts | Navigate to Reports | Revenue charts display with data |
| TC-WEB-4.10.2 | Utilization graphs | View reports | Staff/resource utilization graphs visible |
| TC-WEB-5.9.1 | Service catalog loads | Navigate to Services | List of available services displayed |
| TC-WEB-5.9.2 | Create service | Click "New Service", fill form, submit | Service created successfully |
| TC-WEB-5.9.3 | Edit service | Click on service, modify details, save | Service updated successfully |
| TC-WEB-5.9.4 | Create service with default buffer | Create service with "Default buffer time" filled | Buffer shown in service list and form after save |
| TC-WEB-5.9.5 | Edit service buffer | Open existing service, change default buffer minutes | Updated value persisted after save |
### 4.11 Settings UI
### 5.10 Staff Management UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.11.1 | Configuration page | Navigate to Settings | Settings page loads without errors |
| TC-WEB-4.11.2 | Form interactions | Modify settings, save | Settings saved successfully, changes reflected |
| TC-WEB-5.10.1 | Staff list loads | Navigate to Staff | List of staff members displayed |
| TC-WEB-5.10.2 | Role display | View staff member | Staff role/permissions clearly visible |
### 4.12 Navigation
### 5.11 Invoicing & Payments UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.12.1 | Sidebar/menu links | Click navigation items | Each section loads correctly |
| TC-WEB-4.12.2 | All sections reachable | Navigate through all menu items | All sections accessible, no 404 errors |
| TC-WEB-4.12.3 | No broken links | Test all navigation paths | All links work, no broken routes |
| TC-WEB-5.11.1 | Invoice list loads | Navigate to Invoices | List of invoices displayed with status |
| TC-WEB-5.11.2 | Payment flow | Click "Pay" on unpaid invoice, complete payment | Payment processed, invoice marked as paid |
| TC-WEB-5.11.3 | Receipts view | View paid invoice | Receipt/payment details displayed |
### 4.13 Mobile / PWA
### 5.12 Customer Portal UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.13.1 | Responsive at 390x844 | Resize viewport to mobile dimensions | Layout adapts correctly, no horizontal scroll |
| TC-WEB-4.13.2 | PWA install prompt | Load app on supported browser | Install prompt appears when criteria met |
| TC-WEB-4.13.3 | Touch interactions | Use touch gestures on mobile | All interactions work with touch input |
| TC-WEB-5.12.1 | Client-facing view | Log in as client persona | Customer portal UI displayed |
| TC-WEB-5.12.2 | Appointment list | View client portal appointments | List of client's appointments visible |
| TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
| TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
### 4.14 Error & Empty States
### 5.13 Reports UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-4.14.1 | Form validation | Submit form with invalid data | Appropriate validation errors displayed |
| TC-WEB-4.14.2 | Missing data | Navigate to section with no data | Empty state message displayed, not blank page |
| TC-WEB-4.14.3 | Error boundaries | Trigger error condition | Friendly error message displayed, app doesn't crash |
| TC-WEB-5.13.1 | Revenue charts | Navigate to Reports | Revenue charts display with data |
| TC-WEB-5.13.2 | Utilization graphs | View reports | Staff/resource utilization graphs visible |
## 5. Pass/Fail Criteria
### 5.14 Settings UI
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.14.1 | Configuration page | Navigate to Settings | Settings page loads without errors |
| TC-WEB-5.14.2 | Form interactions | Modify settings, save | Settings saved successfully, changes reflected |
### 5.15 Navigation
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.15.1 | Sidebar/menu links | Click navigation items | Each section loads correctly |
| TC-WEB-5.15.2 | All sections reachable | Navigate through all menu items | All sections accessible, no 404 errors |
| TC-WEB-5.15.3 | No broken links | Test all navigation paths | All links work, no broken routes |
### 5.16 Mobile / PWA
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.16.1 | Responsive at 390x844 | Resize viewport to mobile dimensions | Layout adapts correctly, no horizontal scroll |
| TC-WEB-5.16.2 | PWA install prompt | Load app on supported browser | Install prompt appears when criteria met |
| TC-WEB-5.16.3 | Touch interactions | Use touch gestures on mobile | All interactions work with touch input |
### 5.17 Error & Empty States
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.17.1 | Form validation | Submit form with invalid data | Appropriate validation errors displayed |
| TC-WEB-5.17.2 | Missing data | Navigate to section with no data | Empty state message displayed, not blank page |
| TC-WEB-5.17.3 | Error boundaries | Trigger error condition | Friendly error message displayed, app doesn't crash |
### 5.18 Pet Profile UI — Enhanced Fields (GRO-1178)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.18.1 | Coat type displayed in Grooming tab | Open pet profile, go to Grooming tab | Coat type shown (e.g. "Curly", "Double") |
| TC-WEB-5.18.2 | Preferred cuts displayed | Open Grooming tab | Preferred cuts shown as tags/chips |
| TC-WEB-5.18.3 | Temperament score displayed (read-only) | Open Basic Info tab | 15 star display with score label "(N/5 · staff-set)" |
| TC-WEB-5.18.4 | Temperament flags displayed (read-only) | Open Basic Info tab | Flag chips shown (e.g. "Anxious", "Good with kids") |
| TC-WEB-5.18.5 | Medical alerts in Medical tab | Open Medical tab | Alert cards with type, description, severity badge |
| TC-WEB-5.18.6 | Medical alert severity badges | View Medical tab | Low=green, Medium=amber, High=red badges |
| TC-WEB-5.18.7 | Edit pet — coat type dropdown | Click Edit on pet, select coat type | Coat type persisted on save |
| TC-WEB-5.18.8 | Edit pet — add medical alert | Click Edit, add alert with type + severity, save | Alert appears in Medical tab after save |
| TC-WEB-5.18.9 | Edit pet — remove medical alert | Click Edit, remove an alert, save | Alert removed after save |
| TC-WEB-5.18.10 | Edit pet — add preferred cut (Enter) | Click Edit, type cut name, press Enter | Cut tag added; persists after save |
| TC-WEB-5.18.11 | Edit pet — remove preferred cut | Click Edit, click X on cut tag | Cut removed; not persisted after save |
| TC-WEB-5.18.12 | Medical alert validation | Click Edit, add alert with empty type, try to save | Error "Type is required"; form not submitted |
| TC-WEB-5.18.13 | Temperament fields read-only | View edit form for pet with temperament data | Temperament score and flags not editable (display only) |
### 5.19 Booking Wizard — Pet Size & Coat (GRO-1174)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.19.1 | Pet size dropdown visible | Step 3 of booking wizard (pet details) | Pet size dropdown shown after breed field with options: Small, Medium, Large, X-Large |
| TC-WEB-5.19.2 | Coat type dropdown visible | Step 3 of booking wizard | Coat type dropdown shown after pet size with options: Smooth, Double, Curly, Wire, Long, Hairless |
| TC-WEB-5.19.3 | Size/coat pre-fill from URL | Navigate to booking with `?petSizeCategory=large&petCoatType=curly` | Fields pre-filled with provided values |
| TC-WEB-5.19.4 | Size/coat optional | Proceed through booking without selecting size/coat | Booking completes successfully |
| TC-WEB-5.19.5 | Confirmation shows appointment duration | Confirm booking step | Service duration shown as "X min appointment" (buffer not exposed) |
| TC-WEB-5.19.6 | Confirmation shows pet size/coat | Confirm booking with size/coat selected | Size and coat type shown on pet card in confirmation |
| TC-WEB-5.19.7 | Availability uses buffer for large/x-large | Select large or x-large size, check availability | Availability slots reflect service duration + buffer for large/x-large |
| TC-WEB-5.19.8 | Form reset clears size/coat | Complete booking, click "Book another" | Size and coat fields reset to empty |
| TC-WEB-5.19.9 | New pet record has size/coat | Complete booking, view created pet in admin | Pet record shows selected size and coat type |
### 5.20 Buffer Rules Management — Admin UI (GRO-1173)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.20.1 | Buffer rules section loads | Navigate to Settings page (admin) | "Buffer Rules" section visible with "+ Add Rule" button |
| TC-WEB-5.20.2 | Add rule — required fields only | Click "+ Add Rule", select a service, enter buffer minutes, submit | Rule created, appears in list below |
| TC-WEB-5.20.3 | Add rule — with size category | Add rule, select service + size category + buffer minutes | Rule created with size tag shown in list |
| TC-WEB-5.20.4 | Add rule — with coat type | Add rule, select service + coat type + buffer minutes | Rule created with coat tag shown in list |
| TC-WEB-5.20.5 | Add rule — with both size and coat | Add rule, select service + size + coat + buffer minutes | Rule created with both tags shown |
| TC-WEB-5.20.6 | Validation — missing service | Submit form without selecting service | Error: "Service and valid buffer minutes are required" |
| TC-WEB-5.20.7 | Validation — zero buffer | Submit form with 0 buffer minutes | Error: "Service and valid buffer minutes are required" |
| TC-WEB-5.20.8 | Edit rule inline | Click "Edit" on a rule, change buffer value, click "Save" | Rule updated in list |
| TC-WEB-5.20.9 | Cancel edit | Click "Edit", then "Cancel" | Original value unchanged |
| TC-WEB-5.20.10 | Delete rule — confirmation | Click "Delete" on a rule | Confirmation prompt appears |
| TC-WEB-5.20.11 | Confirm delete | On confirmation prompt, click "Confirm" | Rule removed from list |
| TC-WEB-5.20.12 | Cancel delete | On confirmation prompt, click "Cancel" | Rule remains in list |
| TC-WEB-5.20.13 | Empty state | No rules exist | Message: "No buffer rules configured yet." |
| TC-WEB-5.20.14 | Toggle form | Click "+ Add Rule", then "Cancel" | Form hidden, no rule created |
### 5.21 Service Default Buffer Minutes (GRO-1173)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.21.1 | Default buffer shown in table | Navigate to Services page | "Default Buffer" column visible in services table |
| TC-WEB-5.21.2 | New service default is 0 | Click "+ Add Service" | Default Buffer field pre-filled with 0 |
| TC-WEB-5.21.3 | Create service with buffer | Fill service form, set Default Buffer = 10, submit | Service created with 10 min default buffer |
| TC-WEB-5.21.4 | Edit service — view buffer | Edit an existing service | Current default buffer value shown in form |
| TC-WEB-5.21.5 | Update buffer on existing service | Edit service, change Default Buffer to 15, save | Buffer updated, table shows 15 min |
| TC-WEB-5.21.6 | Buffer field — zero allowed | Set Default Buffer to 0, save | Service saved with 0 (no default buffer) |
| TC-WEB-5.21.7 | Buffer field — integer only | Enter non-integer value | Field restricts to integer values |
### 5.22 Pet Profile — Size Category & Coat Type (GRO-1173)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.22.1 | Size category dropdown visible | Open Add Pet or Edit Pet form (portal) | "Size Category" dropdown visible with options: Small, Medium, Large, X-Large |
| TC-WEB-5.22.2 | Coat type dropdown visible | Open Add Pet or Edit Pet form | "Coat Type" dropdown visible with options: Smooth, Double, Curly, Wire, Long, Hairless |
| TC-WEB-5.22.3 | Size and coat both optional | Submit pet form without selecting size or coat | Pet saved successfully |
| TC-WEB-5.22.4 | Save pet with size category | Select "Large", fill required fields, save | Pet saved with size = "large" |
| TC-WEB-5.22.5 | Save pet with coat type | Select "Curly", fill required fields, save | Pet saved with coat = "curly" |
| TC-WEB-5.22.6 | Size and coat persisted | Save pet with size + coat, edit again | Both fields retain their selected values |
| TC-WEB-5.22.7 | Clear size | Select size, then clear back to default | Size cleared on save |
### 5.23 Pet Profile — API Persistence & Save UX (GRO-1470)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.23.1 | Save pet — API persistence | Edit a pet, change a field (e.g. coat type), click Save, reload the page | Changed field retained after reload (proves PATCH round-trip to server) |
| TC-WEB-5.23.2 | Save pet — error state | Trigger an API save failure (e.g. network error) | Error message displayed; edit form stays open; no data cleared |
| TC-WEB-5.23.3 | Save pet — saving indicator | Click Save | Spinner/indicator shown while request is in flight; form controls disabled |
## 6. Pass/Fail Criteria
**Pass:**
- All test cases execute without errors
@@ -152,7 +297,7 @@ GroomBook Web is the React 19 PWA frontend for the GroomBook pet grooming manage
- Screenshot or screen recording of failure
- Error details from browser console or network tab
## 6. Update Policy
## 7. Update Policy
**Any PR that changes user-facing behaviour MUST update this file.**
@@ -161,4 +306,4 @@ When modifying the GroomBook Web application in ways that affect the user interf
2. Add new test cases for new features or flows
3. Modify existing test cases if behaviour changes
4. Remove test cases for deprecated features
5. Reference the updated section(s) in the PR description (e.g., "Updated UAT_PLAYBOOK.md §4.5 — new appointment group feature")
5. Reference the updated section(s) in the PR description (e.g., "Updated UAT_PLAYBOOK.md §5.5 — new appointment group feature")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

+39
View File
@@ -39,6 +39,12 @@ export interface Pet {
cutStyle: string | null;
shampooPreference: string | null;
specialCareNotes: string | null;
coatType?: string | null;
petSizeCategory?: string | null;
preferredCuts: string[];
medicalAlerts: MedicalAlert[];
temperamentScore?: number;
temperamentFlags?: string[];
customFields: Record<string, string>;
photoKey?: string;
photoUploadedAt?: string;
@@ -65,6 +71,7 @@ export interface Service {
basePriceCents: number;
durationMinutes: number;
active: boolean;
defaultBufferMinutes?: number;
createdAt: string;
updatedAt: string;
}
@@ -208,3 +215,35 @@ export interface PaginatedList<T> {
page: number;
pageSize: number;
}
export type AlertSeverity = "low" | "medium" | "high";
export interface MedicalAlert {
id: string;
type: string;
description: string;
severity: AlertSeverity;
}
export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless";
export interface NextAppointment {
id: string;
startTime: string;
serviceName: string;
}
export interface PetProfileSummary {
id: string;
name: string;
breed: string | null;
dateOfBirth: string | null;
weightKg: number | null;
coatType: CoatType | null;
temperamentScore: number | null;
temperamentFlags: string[];
medicalAlerts: MedicalAlert[];
preferredCuts: string[];
recentVisits: GroomingVisitLog[];
nextAppointment: NextAppointment | null;
}
+157
View File
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PetForm } from "../portal/sections/PetForm.js";
import type { Pet } from "@groombook/types";
const BASE_PET: Pet = {
id: "pet-1",
clientId: "client-1",
name: "Buddy",
species: "dog",
breed: "Labrador",
weightKg: 25,
dateOfBirth: "2020-03-15T00:00:00.000Z",
healthAlerts: null,
groomingNotes: null,
cutStyle: null,
shampooPreference: null,
specialCareNotes: null,
customFields: {},
coatType: null,
preferredCuts: [],
medicalAlerts: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
};
describe("PetForm", () => {
const onSave = vi.fn();
const onCancel = vi.fn();
beforeEach(() => {
onSave.mockClear();
onCancel.mockClear();
});
// ── Coat type ───────────────────────────────────────────────────────────────
it("allows coat type selection from dropdown", () => {
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
const select = screen.getByRole("combobox", { name: /coat type/i });
fireEvent.change(select, { target: { value: "curly" } });
expect((select as HTMLSelectElement).value).toBe("curly");
});
it("persists coat type on save", () => {
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
fireEvent.change(screen.getByRole("combobox", { name: /coat type/i }), { target: { value: "double" } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ coatType: "double" })
);
});
// ── Preferred cuts tag input ────────────────────────────────────────────────
it("adds a cut when Enter is pressed", () => {
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
const input = screen.getByPlaceholderText(/type a cut name/i);
fireEvent.change(input, { target: { value: "Puppy Cut" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("Puppy Cut")).toBeTruthy();
});
it("adds a cut when the + button is clicked", () => {
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
const input = screen.getByPlaceholderText(/type a cut name/i);
fireEvent.change(input, { target: { value: "Teddy Bear" } });
fireEvent.click(screen.getByRole("button", { name: "Add" }));
expect(screen.getByText("Teddy Bear")).toBeTruthy();
});
it("removes a cut when X is clicked", () => {
const petWithCuts: Pet = {
...BASE_PET,
preferredCuts: ["Puppy Cut", "Teddy Bear"],
};
render(<PetForm pet={petWithCuts} onSave={onSave} onCancel={onCancel} />);
const puppyCutSpans = screen.getAllByText("Puppy Cut");
const puppyCutTag = puppyCutSpans[0]?.closest("span");
if (!puppyCutTag) return;
const removeBtn = puppyCutTag.querySelector("button");
if (!removeBtn) return;
fireEvent.click(removeBtn);
expect(screen.queryByText("Puppy Cut")).toBeNull();
expect(screen.getByText("Teddy Bear")).toBeTruthy();
});
it("includes preferred cuts in save payload", () => {
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
fireEvent.change(screen.getByPlaceholderText(/type a cut name/i), { target: { value: "Puppy Cut" } });
fireEvent.keyDown(screen.getByPlaceholderText(/type a cut name/i), { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ preferredCuts: ["Puppy Cut"] })
);
});
// ── Medical alerts ───────────────────────────────────────────────────────────
it("adds a medical alert", () => {
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: /add alert/i }));
expect(screen.getByPlaceholderText(/alert type/i)).toBeTruthy();
});
it("removes a medical alert", () => {
const petWithAlert: Pet = {
...BASE_PET,
medicalAlerts: [{ id: "alert-1", type: "Allergic to chicken", description: "Causes hives", severity: "high" }],
};
render(<PetForm pet={petWithAlert} onSave={onSave} onCancel={onCancel} />);
const removeButtons = screen.getAllByRole("button", { name: "" });
if (removeButtons.length === 0) return;
const removeButton = removeButtons[0]!;
if (!removeButton) return;
fireEvent.click(removeButton);
expect(screen.queryByText("Allergic to chicken")).toBeNull();
});
it("validates alert type is non-empty", () => {
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: /add alert/i }));
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(screen.getByText(/type is required/i)).toBeTruthy();
expect(onSave).not.toHaveBeenCalled();
});
it("shows medical alerts in save payload", () => {
const petWithAlert: Pet = {
...BASE_PET,
medicalAlerts: [{ id: "alert-1", type: "Sensitive skin", description: "Use hypoallergenic shampoo only", severity: "medium" }],
};
render(<PetForm pet={petWithAlert} onSave={onSave} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
medicalAlerts: expect.arrayContaining([
expect.objectContaining({ type: "Sensitive skin", severity: "medium" }),
]),
})
);
});
// ── Temperament read-only display ─────────────────────────────────────────────
it("displays temperament score as read-only stars", () => {
const petWithTemperament: Pet = {
...BASE_PET,
temperamentScore: 4,
temperamentFlags: ["Anxious", "Good with kids"],
};
render(<PetForm pet={petWithTemperament} onSave={onSave} onCancel={onCancel} />);
expect(screen.getByText("(4/5)")).toBeTruthy();
expect(screen.getByText("Anxious")).toBeTruthy();
expect(screen.getByText("Good with kids")).toBeTruthy();
});
});
+157
View File
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { PetProfileCard } from "../components/PetProfileCard.js";
const FULL_SUMMARY = {
id: "pet-1",
name: "Buddy",
breed: "Golden Retriever",
dateOfBirth: "2022-03-15",
weightKg: 30.5,
coatType: "double",
temperamentScore: 4,
temperamentFlags: ["anxious", "friendly"],
medicalAlerts: [
{ id: "a1", type: "allergy", description: "Chicken allergy", severity: "high" },
{ id: "a2", type: "condition", description: "Hip dysplasia", severity: "medium" },
],
preferredCuts: ["teddy bear", "puppy cut"],
recentVisits: [
{ id: "v1", petId: "pet-1", appointmentId: "appt-1", staffId: "staff-1", cutStyle: "teddy bear", productsUsed: "oat shampoo", notes: "Good boy", groomedAt: "2025-05-01T10:00:00Z", createdAt: "2025-05-01T10:00:00Z" },
{ id: "v2", petId: "pet-1", appointmentId: "appt-2", staffId: "staff-2", cutStyle: "puppy cut", productsUsed: null, notes: null, groomedAt: "2025-04-01T10:00:00Z", createdAt: "2025-04-01T10:00:00Z" },
],
nextAppointment: { id: "appt-3", startTime: "2025-06-01T09:00:00Z", serviceName: "Full groom" },
};
const EMPTY_SUMMARY = {
id: "pet-2",
name: "Whiskers",
breed: null,
dateOfBirth: null,
weightKg: null,
coatType: null,
temperamentScore: null,
temperamentFlags: [],
medicalAlerts: [],
preferredCuts: [],
recentVisits: [],
nextAppointment: null,
};
beforeEach(() => {
vi.restoreAllMocks();
});
describe("PetProfileCard", () => {
it("shows loading skeleton while fetching", () => {
global.fetch = vi.fn(() => new Promise(() => {})) as unknown as typeof fetch;
render(<PetProfileCard petId="pet-1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("renders full profile data correctly", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: async () => FULL_SUMMARY,
} as Response)
) as unknown as typeof fetch;
render(<PetProfileCard petId="pet-1" />);
await waitFor(() => {
expect(screen.getByText("Buddy")).toBeInTheDocument();
});
expect(screen.getByText("Golden Retriever")).toBeInTheDocument();
// age computed from DOB
expect(screen.getByText(/yr/)).toBeInTheDocument();
// weight
expect(screen.getByText(/30.5 kg/)).toBeInTheDocument();
// coat type badge
expect(screen.getByText("double")).toBeInTheDocument();
// medical alerts
expect(screen.getByText("Chicken allergy")).toBeInTheDocument();
expect(screen.getByText("Hip dysplasia")).toBeInTheDocument();
// preferred cuts
expect(screen.getByText("teddy bear")).toBeInTheDocument();
});
it("displays severity-colored badges for medical alerts", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: async () => FULL_SUMMARY,
} as Response)
) as unknown as typeof fetch;
render(<PetProfileCard petId="pet-1" />);
await waitFor(() => {
expect(screen.getByText("Chicken allergy")).toBeInTheDocument();
});
const highAlert = screen.getByText("Chicken allergy").closest("span");
expect(highAlert).toHaveStyle({ color: "#dc2626" }); // high = red
const mediumAlert = screen.getByText("Hip dysplasia").closest("span");
expect(mediumAlert).toHaveStyle({ color: "#d97706" }); // medium = amber
});
it("handles empty pet data gracefully", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: async () => EMPTY_SUMMARY,
} as Response)
) as unknown as typeof fetch;
render(<PetProfileCard petId="pet-2" />);
await waitFor(() => {
expect(screen.getByText("Whiskers")).toBeInTheDocument();
});
// no section labels with no data
expect(screen.queryByText(/medical alerts/i)).not.toBeInTheDocument();
expect(screen.queryByText(/preferred cuts/i)).not.toBeInTheDocument();
expect(screen.queryByText(/recent visits/i)).not.toBeInTheDocument();
});
it("shows error message on fetch failure", async () => {
global.fetch = vi.fn(() => Promise.reject(new Error("network error"))) as unknown as typeof fetch;
render(<PetProfileCard petId="pet-1" />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
});
it("refetches when petId changes", async () => {
const fetchMock = vi.fn((url: string) => {
if ((url as string).includes("pet-1")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ ...FULL_SUMMARY, name: "Buddy" }),
} as Response);
}
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ ...EMPTY_SUMMARY, name: "Whiskers" }),
} as Response);
}) as unknown as typeof fetch;
global.fetch = fetchMock;
const { rerender } = render(<PetProfileCard petId="pet-1" />);
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-1/profile-summary");
rerender(<PetProfileCard petId="pet-2" />);
await waitFor(() => expect(screen.getByText("Whiskers")).toBeInTheDocument());
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-2/profile-summary");
});
});
+282
View File
@@ -0,0 +1,282 @@
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
interface Service {
id: string;
name: string;
description?: string;
basePriceCents: number;
durationMinutes: number;
active: boolean;
}
interface BufferRule {
id: string;
serviceId: string;
serviceName: string;
sizeCategory?: string;
coatType?: string;
bufferMinutes: number;
createdAt: string;
updatedAt: string;
}
interface BufferRuleForm {
serviceId: string;
sizeCategory: string;
coatType: string;
bufferMinutes: string;
}
const EMPTY_FORM: BufferRuleForm = {
serviceId: "",
sizeCategory: "",
coatType: "",
bufferMinutes: "",
};
const SIZE_OPTIONS = ["", "small", "medium", "large", "xlarge"] as const;
const COAT_OPTIONS = ["", "smooth", "double", "wire", "curly", "long", "hairless"] as const;
export function BufferRulesSection() {
const [rules, setRules] = useState<BufferRule[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<BufferRuleForm>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editBuffer, setEditBuffer] = useState<string>("");
useEffect(() => {
Promise.all([
fetch("/api/buffer-rules").then(r => r.ok ? r.json() : []),
fetch("/api/services?includeInactive=true").then(r => r.ok ? r.json() : []),
]).then(([rulesData, servicesData]) => {
setRules(rulesData as BufferRule[]);
setServices(servicesData as Service[]);
}).catch(() => setError("Failed to load")).finally(() => setLoading(false));
}, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const mins = parseInt(form.bufferMinutes);
if (!form.serviceId || isNaN(mins) || mins <= 0) {
setFormError("Service and valid buffer minutes are required.");
return;
}
setSaving(true);
setFormError(null);
try {
const body: Record<string, string | number> = {
serviceId: form.serviceId,
bufferMinutes: mins,
};
if (form.sizeCategory) body.sizeCategory = form.sizeCategory;
if (form.coatType) body.coatType = form.coatType;
const res = await fetch("/api/buffer-rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({})) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const newRule = await res.json() as BufferRule;
setRules(prev => [...prev, newRule]);
setShowForm(false);
setForm(EMPTY_FORM);
} catch (e: unknown) {
setFormError(e instanceof Error ? e.message : "Failed to create rule");
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
setDeletingId(id);
try {
await fetch(`/api/buffer-rules/${id}`, { method: "DELETE" });
setRules(prev => prev.filter(r => r.id !== id));
} finally {
setDeletingId(null);
setConfirmDeleteId(null);
}
}
function startEdit(rule: BufferRule) {
setEditingId(rule.id);
setEditBuffer(String(rule.bufferMinutes));
}
async function saveEdit(rule: BufferRule) {
const mins = parseInt(editBuffer);
if (isNaN(mins) || mins <= 0) return;
setSaving(true);
try {
const res = await fetch(`/api/buffer-rules/${rule.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bufferMinutes: mins }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const updated = await res.json() as BufferRule;
setRules(prev => prev.map(r => r.id === updated.id ? updated : r));
} catch {
// silent fail
} finally {
setSaving(false);
setEditingId(null);
setEditBuffer("");
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 size={20} className="animate-spin text-stone-400" />
</div>
);
}
if (error) {
return (
<div className="py-4 text-sm text-red-500">{error}</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-base font-semibold text-stone-800">Buffer Rules</h2>
<p className="text-sm text-stone-500">Extra time rules per service / pet size / coat type</p>
</div>
<button
onClick={() => { setShowForm(!showForm); setFormError(null); }}
className="px-3 py-1.5 bg-(--color-primary) text-white text-sm rounded-lg hover:bg-(--color-primary-hover)"
>
{showForm ? "Cancel" : "+ Add Rule"}
</button>
</div>
{showForm && (
<form onSubmit={handleCreate} className="mb-6 p-4 bg-stone-50 rounded-xl border border-stone-200 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">Service *</label>
<select
value={form.serviceId}
onChange={e => setForm(f => ({ ...f, serviceId: e.target.value }))}
required
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
>
<option value="">Select service</option>
{services.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">Buffer (minutes) *</label>
<input
type="number"
min="1"
step="1"
value={form.bufferMinutes}
onChange={e => setForm(f => ({ ...f, bufferMinutes: e.target.value }))}
required
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">Size Category</label>
<select
value={form.sizeCategory}
onChange={e => setForm(f => ({ ...f, sizeCategory: e.target.value }))}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
>
<option value="">Any</option>
{SIZE_OPTIONS.filter(s => s).map(s => (
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">Coat Type</label>
<select
value={form.coatType}
onChange={e => setForm(f => ({ ...f, coatType: e.target.value }))}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
>
<option value="">Any</option>
{COAT_OPTIONS.filter(c => c).map(c => (
<option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>
))}
</select>
</div>
</div>
{formError && <p className="text-sm text-red-500">{formError}</p>}
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-(--color-primary) text-white text-sm rounded-lg hover:bg-(--color-primary-hover) disabled:opacity-60"
>
{saving ? "Saving…" : "Create Rule"}
</button>
</form>
)}
{rules.length === 0 && !showForm ? (
<p className="text-sm text-stone-400 py-6 text-center">No buffer rules configured yet.</p>
) : (
<div className="space-y-2">
{rules.map(rule => (
<div key={rule.id} className="flex items-center gap-3 p-3 bg-white rounded-xl border border-stone-200">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-stone-800 truncate">{rule.serviceName}</div>
<div className="text-xs text-stone-500 flex gap-2 flex-wrap">
{rule.sizeCategory && <span>Size: {rule.sizeCategory}</span>}
{rule.coatType && <span>Coat: {rule.coatType}</span>}
</div>
</div>
{editingId === rule.id ? (
<div className="flex items-center gap-2">
<input
type="number"
min="1"
value={editBuffer}
onChange={e => setEditBuffer(e.target.value)}
className="w-20 border border-stone-200 rounded px-2 py-1 text-sm"
/>
<span className="text-xs text-stone-500">min</span>
<button onClick={() => saveEdit(rule)} disabled={saving} className="text-xs text-green-600 font-medium">Save</button>
<button onClick={() => setEditingId(null)} className="text-xs text-stone-500">Cancel</button>
</div>
) : (
<>
<span className="text-sm font-medium text-stone-700">{rule.bufferMinutes} min</span>
<button onClick={() => startEdit(rule)} className="text-xs text-stone-500 hover:text-stone-700 px-2">Edit</button>
</>
)}
{confirmDeleteId === rule.id ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-500">Delete?</span>
<button onClick={() => handleDelete(rule.id)} disabled={deletingId === rule.id} className="text-xs text-red-600 font-medium">Confirm</button>
<button onClick={() => setConfirmDeleteId(null)} className="text-xs text-stone-500">Cancel</button>
</div>
) : (
<button onClick={() => setConfirmDeleteId(rule.id)} className="text-xs text-red-400 hover:text-red-600">Delete</button>
)}
</div>
))}
</div>
)}
</div>
);
}
+238
View File
@@ -0,0 +1,238 @@
import { useEffect, useState } from "react";
import type { MedicalAlert, PetProfileSummary } from "@groombook/types";
import { PetPhotoDisplay } from "./PetPhotoDisplay.js";
interface Props {
petId: string;
}
type LoadState =
| { status: "idle" }
| { status: "loading" }
| { status: "loaded"; data: PetProfileSummary }
| { status: "error"; message: string };
function computeAge(dateOfBirth: string | null): string | null {
if (!dateOfBirth) return null;
const birth = new Date(dateOfBirth);
const now = new Date();
const totalMonths = (now.getFullYear() - birth.getFullYear()) * 12 + (now.getMonth() - birth.getMonth());
if (totalMonths < 1) return "<1 mo";
if (totalMonths < 12) return `${totalMonths} mo`;
const years = Math.floor(totalMonths / 12);
const months = totalMonths % 12;
if (months === 0) return `${years} yr`;
return `${years} yr ${months} mo`;
}
function TemperamentDots({ score }: { score: number }) {
return (
<div style={{ display: "flex", gap: 3, alignItems: "center" }}>
{[1, 2, 3, 4, 5].map((n) => (
<div
key={n}
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: n <= score ? "var(--color-primary)" : "#e2e8f0",
}}
/>
))}
</div>
);
}
const SEVERITY_STYLES: Record<MedicalAlert["severity"], { bg: string; color: string; border: string }> = {
high: { bg: "#fef2f2", color: "#dc2626", border: "#fca5a5" },
medium: { bg: "#fffbeb", color: "#d97706", border: "#fde68a" },
low: { bg: "#eff6ff", color: "#2563eb", border: "#bfdbfe" },
};
function MedicalAlertBadge({ alert }: { alert: MedicalAlert }) {
const s = SEVERITY_STYLES[alert.severity];
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
fontSize: 11,
fontWeight: 600,
padding: "0.15rem 0.45rem",
borderRadius: 99,
background: s.bg,
color: s.color,
border: `1px solid ${s.border}`,
}}
>
{alert.description}
</span>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{ fontSize: 10, fontWeight: 700, color: "#9ca3af", letterSpacing: "0.05em", marginBottom: "0.25rem" }}>
{children}
</div>
);
}
export function PetProfileCard({ petId }: Props) {
const [state, setState] = useState<LoadState>({ status: "idle" });
useEffect(() => {
setState({ status: "loading" });
fetch(`/api/pets/${petId}/profile-summary`)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<PetProfileSummary>;
})
.then((data) => setState({ status: "loaded", data }))
.catch((e: unknown) => setState({ status: "error", message: e instanceof Error ? e.message : "Unknown error" }));
}, [petId]);
if (state.status === "idle" || state.status === "loading") {
return (
<div style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "1rem",
background: "#fff",
boxShadow: "0 1px 3px rgba(0,0,0,0.04)",
}}>
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
<div style={{ width: 64, height: 64, borderRadius: 12, background: "linear-gradient(90deg,#f0ebe4 25%,#e8e0d8 50%,#f0ebe4 75%)", backgroundSize: "200% 100%", animation: "shimmer 1.5s infinite", flexShrink: 0 }} />
<div style={{ flex: 1 }}>
<div style={{ height: 14, borderRadius: 4, background: "#f0ebe4", width: "60%", marginBottom: 6, animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />
<div style={{ height: 12, borderRadius: 4, background: "#f0ebe4", width: "40%", marginBottom: 6, animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />
<div style={{ height: 12, borderRadius: 4, background: "#f0ebe4", width: "50%", animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />
</div>
</div>
<div style={{ height: 10, borderRadius: 4, background: "#f0ebe4", width: "30%", marginBottom: 8, animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{[1, 2, 3].map((n) => <div key={n} style={{ height: 22, width: 72, borderRadius: 99, background: "#f0ebe4", animation: "shimmer 1.5s infinite", backgroundSize: "200% 100%" }} />)}
</div>
</div>
);
}
if (state.status === "error") {
return (
<div style={{
border: "1px solid #fca5a5",
borderRadius: 10,
padding: "1rem",
background: "#fef2f2",
color: "#dc2626",
fontSize: 13,
}}>
Failed to load profile: {state.message}
</div>
);
}
const { data } = state;
const age = computeAge(data.dateOfBirth);
return (
<div style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "1rem",
background: "#fff",
boxShadow: "0 1px 3px rgba(0,0,0,0.04)",
}}>
{/* Header */}
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
<PetPhotoDisplay petId={petId} size={64} />
<div style={{ flex: 1, minWidth: 0 }}>
<strong style={{ fontSize: 16 }}>{data.name}</strong>
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
{data.breed ?? "Unknown breed"}
{age && <span> · {age}</span>}
</div>
{data.weightKg != null && (
<div style={{ fontSize: 12, color: "#6b7280" }}>{data.weightKg} kg</div>
)}
</div>
</div>
{/* Coat type */}
{data.coatType && (
<div style={{ marginBottom: "0.6rem" }}>
<SectionLabel>COAT TYPE</SectionLabel>
<span style={{ fontSize: 12, padding: "0.15rem 0.5rem", background: "#f0fdf4", color: "#166534", border: "1px solid #bbf7d0", borderRadius: 99, fontWeight: 500 }}>
{data.coatType}
</span>
</div>
)}
{/* Temperament */}
{(data.temperamentScore != null || data.temperamentFlags.length > 0) && (
<div style={{ marginBottom: "0.6rem" }}>
<SectionLabel>TEMPERAMENT</SectionLabel>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
{data.temperamentScore != null && <TemperamentDots score={data.temperamentScore} />}
{data.temperamentFlags.map((flag) => (
<span key={flag} style={{ fontSize: 11, padding: "0.1rem 0.4rem", background: "#f5f3ff", color: "#6d28d9", border: "1px solid #ddd6fe", borderRadius: 4, fontWeight: 500 }}>
{flag}
</span>
))}
</div>
</div>
)}
{/* Medical alerts — most prominent */}
{data.medicalAlerts.length > 0 && (
<div style={{ marginBottom: "0.6rem" }}>
<SectionLabel>MEDICAL ALERTS</SectionLabel>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem" }}>
{data.medicalAlerts.map((alert) => (
<MedicalAlertBadge key={alert.id} alert={alert} />
))}
</div>
</div>
)}
{/* Preferred cuts */}
{data.preferredCuts.length > 0 && (
<div style={{ marginBottom: "0.6rem" }}>
<SectionLabel>PREFERRED CUTS</SectionLabel>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem" }}>
{data.preferredCuts.map((cut) => (
<span key={cut} style={{ fontSize: 11, padding: "0.15rem 0.45rem", background: "#f8fafc", color: "#374151", border: "1px solid #e2e8f0", borderRadius: 4 }}>
{cut}
</span>
))}
</div>
</div>
)}
{/* Recent visits */}
{data.recentVisits.length > 0 && (
<div style={{ marginBottom: "0.6rem" }}>
<SectionLabel>RECENT VISITS</SectionLabel>
{data.recentVisits.slice(0, 3).map((log) => (
<div key={log.id} style={{ fontSize: 12, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
{log.cutStyle && <span> · {log.cutStyle}</span>}
{log.staffId && <span> ·</span>}
</div>
))}
</div>
)}
{/* Next appointment */}
{data.nextAppointment && (
<div>
<SectionLabel>NEXT APPOINTMENT</SectionLabel>
<div style={{ fontSize: 12, color: "#374151" }}>
{new Date(data.nextAppointment.startTime).toLocaleDateString()} {data.nextAppointment.serviceName}
</div>
</div>
)}
</div>
);
}
+9 -1
View File
@@ -1,5 +1,8 @@
import { useEffect, useState, useCallback, useRef } from "react";
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
import { PetProfileCard } from "../components/PetProfileCard.js";
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -113,6 +116,7 @@ export function AppointmentsPage() {
// null key = unassigned; staffId string = that groomer; undefined set = all visible
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
const [selectedPetId, setSelectedPetId] = useState<string | null>(null);
const weekEnd = addDays(weekStart, 6);
@@ -510,7 +514,10 @@ export function AppointmentsPage() {
<Field label="Pet">
<select
value={form.petId}
onChange={(e) => setForm((f) => ({ ...f, petId: e.target.value }))}
onChange={(e) => {
setForm((f) => ({ ...f, petId: e.target.value }));
setSelectedPetId(e.target.value || null);
}}
required
disabled={!form.clientId}
style={inputStyle}
@@ -521,6 +528,7 @@ export function AppointmentsPage() {
))}
</select>
</Field>
{form.petId && <PetProfileCard petId={form.petId} />}
<Field label="Service">
<select
value={form.serviceId}
+57 -7
View File
@@ -13,6 +13,8 @@ interface BookingBody {
petName: string;
petSpecies: string;
petBreed: string;
petSizeCategory: string;
petCoatType: string;
notes: string;
}
@@ -123,6 +125,8 @@ export function BookPage() {
petName: "",
petSpecies: "",
petBreed: "",
petSizeCategory: "",
petCoatType: "",
notes: "",
});
const [formError, setFormError] = useState<string | null>(null);
@@ -136,7 +140,9 @@ export function BookPage() {
const petName = searchParams.get("petName");
const petSpecies = searchParams.get("petSpecies");
const petBreed = searchParams.get("petBreed");
if (clientName || clientEmail || clientPhone || petName || petSpecies || petBreed) {
const petSizeCategory = searchParams.get("petSizeCategory");
const petCoatType = searchParams.get("petCoatType");
if (clientName || clientEmail || clientPhone || petName || petSpecies || petBreed || petSizeCategory || petCoatType) {
setForm((f) => ({
...f,
...(clientName && { clientName }),
@@ -145,6 +151,8 @@ export function BookPage() {
...(petName && { petName }),
...(petSpecies && { petSpecies }),
...(petBreed && { petBreed }),
...(petSizeCategory && { petSizeCategory }),
...(petCoatType && { petCoatType }),
}));
}
}, [searchParams]);
@@ -168,14 +176,18 @@ export function BookPage() {
if (!selectedService || !date) return;
setSlotsLoading(true);
setSelectedSlot(null);
fetch(
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
)
const params = new URLSearchParams({
serviceId: selectedService.id,
date,
});
if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory);
if (form.petCoatType) params.set("petCoatType", form.petCoatType);
fetch(`/api/book/availability?${params.toString()}`)
.then((r) => r.json() as Promise<string[]>)
.then(setSlots)
.catch(() => setSlots([]))
.finally(() => setSlotsLoading(false));
}, [selectedService, date]);
}, [selectedService, date, form.petSizeCategory, form.petCoatType]);
function goToStep2(svc: Service) {
setSelectedService(svc);
@@ -214,6 +226,8 @@ export function BookPage() {
petName: form.petName,
petSpecies: form.petSpecies,
petBreed: form.petBreed || undefined,
petSizeCategory: form.petSizeCategory || undefined,
petCoatType: form.petCoatType || undefined,
notes: form.notes || undefined,
}),
});
@@ -494,6 +508,36 @@ export function BookPage() {
placeholder="Golden Retriever"
/>
</div>
<div>
<label style={label}>Pet size</label>
<select
style={input}
value={form.petSizeCategory}
onChange={(e) => setForm((f) => ({ ...f, petSizeCategory: e.target.value }))}
>
<option value="">Select size</option>
<option value="small">Small (under 15 lbs)</option>
<option value="medium">Medium (1540 lbs)</option>
<option value="large">Large (4080 lbs)</option>
<option value="x-large">X-Large (over 80 lbs)</option>
</select>
</div>
<div>
<label style={label}>Coat type</label>
<select
style={input}
value={form.petCoatType}
onChange={(e) => setForm((f) => ({ ...f, petCoatType: e.target.value }))}
>
<option value="">Select coat</option>
<option value="smooth">Smooth</option>
<option value="double">Double</option>
<option value="curly">Curly</option>
<option value="wire">Wire</option>
<option value="long">Long</option>
<option value="hairless">Hairless</option>
</select>
</div>
<div>
<label style={label}>Notes for groomer</label>
<textarea
@@ -528,7 +572,7 @@ export function BookPage() {
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)} appointment</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
@@ -545,6 +589,11 @@ export function BookPage() {
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Pet</div>
<div style={{ fontWeight: 600 }}>{form.petName}</div>
<div style={{ color: "#6b7280", textTransform: "capitalize" }}>{form.petSpecies}{form.petBreed ? ` · ${form.petBreed}` : ""}</div>
{(form.petSizeCategory || form.petCoatType) && (
<div style={{ color: "#6b7280", fontSize: 12, marginTop: 2 }}>
{form.petSizeCategory ? `${form.petSizeCategory} · ` : ""}{form.petCoatType ? form.petCoatType : ""}
</div>
)}
</div>
{form.notes && (
<div style={{ gridColumn: "1 / -1" }}>
@@ -599,7 +648,8 @@ export function BookPage() {
setResult(null);
setForm({
serviceId: "", startTime: "", clientName: "", clientEmail: "",
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
clientPhone: "", petName: "", petSpecies: "", petBreed: "",
petSizeCategory: "", petCoatType: "", notes: "",
});
}}
>
+96 -84
View File
@@ -3,6 +3,7 @@ import { useParams, Link } from "react-router-dom";
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
import { PetProfileCard } from "../components/PetProfileCard.js";
export function ClientDetailPage() {
const { clientId } = useParams<{ clientId: string }>();
@@ -13,6 +14,7 @@ export function ClientDetailPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [photoRevisions, setPhotoRevisions] = useState<Record<string, number>>({});
const [expandedPetId, setExpandedPetId] = useState<string | null>(null);
const handlePhotoUploaded = useCallback((petId: string) => {
setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 }));
@@ -136,97 +138,107 @@ export function ClientDetailPage() {
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem" }}>
{pets.map((p) => (
<div key={p.id} style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
{/* Photo + header */}
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.4rem" }}>
<PetPhotoDisplay
petId={p.id}
size={56}
key={`${p.id}-photo-${photoRevisions[p.id] ?? 0}`}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<strong style={{ fontSize: 15 }}>{p.name}</strong>
</div>
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
{p.species}{p.breed ? ` · ${p.breed}` : ""}
</div>
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
<div style={{ marginTop: "0.3rem" }}>
<PetPhotoUpload petId={p.id} onUploaded={() => handlePhotoUploaded(p.id)} />
<div key={p.id}>
{/* Compact pet card — always visible, clickable to expand */}
<div style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)", cursor: "pointer" }}
onClick={() => setExpandedPetId(expandedPetId === p.id ? null : p.id)}>
{/* Photo + header */}
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.4rem" }}>
<PetPhotoDisplay
petId={p.id}
size={56}
key={`${p.id}-photo-${photoRevisions[p.id] ?? 0}`}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<strong style={{ fontSize: 15 }}>{p.name}</strong>
<span style={{ fontSize: 11, color: "#9ca3af" }}>{expandedPetId === p.id ? "▲" : "▼"}</span>
</div>
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
{p.species}{p.breed ? ` · ${p.breed}` : ""}
</div>
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
<div style={{ marginTop: "0.3rem" }}>
<PetPhotoUpload petId={p.id} onUploaded={() => handlePhotoUploaded(p.id)} />
</div>
</div>
</div>
</div>
{p.healthAlerts && (
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
<span style={{ fontWeight: 600 }}> Health alerts:</span> {p.healthAlerts}
</div>
)}
{p.healthAlerts && (
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
<span style={{ fontWeight: 600 }}> Health alerts:</span> {p.healthAlerts}
</div>
)}
{/* Grooming preferences */}
{(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
{p.cutStyle && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Cut:</span> {p.cutStyle}
</div>
)}
{p.shampooPreference && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Shampoo:</span> {p.shampooPreference}
</div>
)}
{p.specialCareNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#92400e" }}>
<span style={{ fontWeight: 600 }}>Special care:</span> {p.specialCareNotes}
</div>
)}
{p.groomingNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
</div>
)}
</div>
)}
{/* Visit history */}
{(() => {
const logs = visitLogs[p.id];
const loadingLogs = logsLoading[p.id];
return (
{/* Grooming preferences */}
{(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.25rem" }}>
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280" }}>VISIT HISTORY</div>
{!logs && !loadingLogs && (
<button
onClick={() => { void loadVisitLogs(p.id); }}
style={{ fontSize: 11, color: "#4f8a6f", background: "none", border: "none", cursor: "pointer", padding: 0 }}
>
Load history
</button>
)}
</div>
{loadingLogs && <div style={{ fontSize: 11, color: "#9ca3af" }}>Loading</div>}
{logs && logs.length === 0 && <div style={{ fontSize: 11, color: "#9ca3af" }}>No visits yet</div>}
{logs && logs.length > 0 && (
<>
{logs.slice(0, 3).map((log) => (
<div key={log.id} style={{ fontSize: 11, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
{log.cutStyle && <span> · {log.cutStyle}</span>}
{log.notes && <span> · {log.notes}</span>}
</div>
))}
{logs.length > 3 && (
<div style={{ fontSize: 11, color: "#6b7280" }}>+{logs.length - 3} more visits</div>
)}
</>
{p.cutStyle && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Cut:</span> {p.cutStyle}
</div>
)}
{p.shampooPreference && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Shampoo:</span> {p.shampooPreference}
</div>
)}
{p.specialCareNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#92400e" }}>
<span style={{ fontWeight: 600 }}>Special care:</span> {p.specialCareNotes}
</div>
)}
{p.groomingNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
</div>
)}
</div>
);
})()}
)}
{/* Visit history */}
{(() => {
const logs = visitLogs[p.id];
const loadingLogs = logsLoading[p.id];
return (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.25rem" }}>
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280" }}>VISIT HISTORY</div>
{!logs && !loadingLogs && (
<button
onClick={(e) => { e.stopPropagation(); void loadVisitLogs(p.id); }}
style={{ fontSize: 11, color: "#4f8a6f", background: "none", border: "none", cursor: "pointer", padding: 0 }}
>
Load history
</button>
)}
</div>
{loadingLogs && <div style={{ fontSize: 11, color: "#9ca3af" }}>Loading</div>}
{logs && logs.length === 0 && <div style={{ fontSize: 11, color: "#9ca3af" }}>No visits yet</div>}
{logs && logs.length > 0 && (
<>
{logs.slice(0, 3).map((log) => (
<div key={log.id} style={{ fontSize: 11, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
{log.cutStyle && <span> · {log.cutStyle}</span>}
{log.notes && <span> · {log.notes}</span>}
</div>
))}
{logs.length > 3 && (
<div style={{ fontSize: 11, color: "#6b7280" }}>+{logs.length - 3} more visits</div>
)}
</>
)}
</div>
);
})()}
</div>
{/* Expanded pet profile card */}
{expandedPetId === p.id && (
<PetProfileCard petId={p.id} />
)}
</div>
))}
</div>
+35
View File
@@ -25,6 +25,8 @@ interface PetForm {
cutStyle: string;
shampooPreference: string;
specialCareNotes: string;
coatType: string;
sizeCategory: string;
}
interface VisitLogForm {
@@ -38,6 +40,7 @@ const EMPTY_CLIENT: ClientForm = { name: "", email: "", phone: "", address: "",
const EMPTY_PET: PetForm = {
name: "", species: "Dog", breed: "", weightStr: "", dob: "",
healthAlerts: "", groomingNotes: "", cutStyle: "", shampooPreference: "", specialCareNotes: "",
coatType: "", sizeCategory: "",
};
const EMPTY_VISIT_LOG: VisitLogForm = { cutStyle: "", productsUsed: "", notes: "", groomedAt: "" };
@@ -209,6 +212,8 @@ export function ClientsPage() {
cutStyle: p.cutStyle ?? "",
shampooPreference: p.shampooPreference ?? "",
specialCareNotes: p.specialCareNotes ?? "",
coatType: p.coatType ?? "",
sizeCategory: p.petSizeCategory ?? "",
});
setPetFormError(null);
setShowPetForm(true);
@@ -315,6 +320,8 @@ export function ClientsPage() {
cutStyle: petForm.cutStyle || undefined,
shampooPreference: petForm.shampooPreference || undefined,
specialCareNotes: petForm.specialCareNotes || undefined,
coatType: petForm.coatType || undefined,
petSizeCategory: petForm.sizeCategory || undefined,
};
const res = editingPet
? await fetch(`/api/pets/${editingPet.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
@@ -690,6 +697,34 @@ export function ClientsPage() {
<Field label="Breed (optional)">
<input value={petForm.breed} onChange={(e) => setPetForm((f) => ({ ...f, breed: e.target.value }))} style={inputStyle} />
</Field>
<Field label="Size Category (optional)">
<select
value={petForm.sizeCategory}
onChange={(e) => setPetForm((f) => ({ ...f, sizeCategory: e.target.value }))}
style={inputStyle}
>
<option value="">Not set</option>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
<option value="xlarge">X-Large</option>
</select>
</Field>
<Field label="Coat Type (optional)">
<select
value={petForm.coatType}
onChange={(e) => setPetForm((f) => ({ ...f, coatType: e.target.value }))}
style={inputStyle}
>
<option value="">Not set</option>
<option value="smooth">Smooth</option>
<option value="double">Double</option>
<option value="curly">Curly</option>
<option value="wire">Wire</option>
<option value="long">Long</option>
<option value="hairless">Hairless</option>
</select>
</Field>
<Field label="Weight kg (optional)">
<input type="number" step="0.1" min="0" value={petForm.weightStr} onChange={(e) => setPetForm((f) => ({ ...f, weightStr: e.target.value }))} style={inputStyle} />
</Field>
+19 -1
View File
@@ -6,6 +6,7 @@ interface ServiceForm {
description: string;
priceStr: string;
durationMinutes: number;
defaultBufferMinutes: number;
active: boolean;
}
@@ -14,6 +15,7 @@ const EMPTY_FORM: ServiceForm = {
description: "",
priceStr: "",
durationMinutes: 60,
defaultBufferMinutes: 0,
active: true,
};
@@ -55,6 +57,7 @@ export function ServicesPage() {
description: s.description ?? "",
priceStr: (s.basePriceCents / 100).toFixed(2),
durationMinutes: s.durationMinutes,
defaultBufferMinutes: s.defaultBufferMinutes ?? 0,
active: s.active,
});
setFormError(null);
@@ -76,6 +79,7 @@ export function ServicesPage() {
description: form.description || undefined,
basePriceCents: Math.round(price * 100),
durationMinutes: form.durationMinutes,
defaultBufferMinutes: form.defaultBufferMinutes,
active: form.active,
};
const res = editing
@@ -138,7 +142,7 @@ export function ServicesPage() {
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
<thead>
<tr style={{ background: "#f8fafc" }}>
{["Name", "Description", "Price", "Duration", "Status", ""].map((h) => (
{["Name", "Description", "Price", "Duration", "Default Buffer", "Status", ""].map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.55rem 0.75rem", borderBottom: "1px solid #e5e7eb", fontSize: 11, fontWeight: 600, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.04em" }}>
{h}
</th>
@@ -152,6 +156,7 @@ export function ServicesPage() {
<td style={tdStyle}>{s.description ?? "—"}</td>
<td style={tdStyle}>${(s.basePriceCents / 100).toFixed(2)}</td>
<td style={tdStyle}>{s.durationMinutes} min</td>
<td style={tdStyle}>{(s as Service & { defaultBufferMinutes?: number }).defaultBufferMinutes ?? 0} min</td>
<td style={tdStyle}>
<button
onClick={() => toggleActive(s)}
@@ -240,6 +245,19 @@ export function ServicesPage() {
style={inputStyle}
/>
</Field>
<Field label="Default Buffer (minutes)">
<input
type="number"
min="0"
step="1"
value={form.defaultBufferMinutes}
onChange={(e) => setForm((f) => ({ ...f, defaultBufferMinutes: parseInt(e.target.value) || 0 }))}
style={inputStyle}
/>
<p style={{ fontSize: 12, color: "#9ca3af", marginTop: "0.2rem" }}>
Default buffer time applied when no specific rule matches
</p>
</Field>
<Field label="Status">
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", cursor: "pointer" }}>
<input
+5
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from "react";
import { useBranding } from "../BrandingContext.js";
import { BufferRulesSection } from "../components/BufferRules.js";
interface AuthProviderConfig {
id: number;
@@ -533,6 +534,10 @@ issuerUrl: authForm.issuerUrl,
{saving ? "Saving..." : "Save Changes"}
</button>
{/* Buffer Rules Section */}
<hr style={{ margin: "2rem 0", border: "none", borderTop: "1px solid #e5e7eb" }} />
<BufferRulesSection />
{/* Auth Provider Section — super users only */}
{currentUser?.isSuperUser && (
<>
+286 -43
View File
@@ -1,87 +1,330 @@
import { useState } from "react";
import { X, Save } from "lucide-react";
import type { Pet } from "../mockData.js";
import { X, Save, Plus, Star, Loader2 } from "lucide-react";
import type { Pet, MedicalAlert, CoatType, AlertSeverity } from "@groombook/types";
const COAT_TYPES: CoatType[] = ["double", "wire", "curly", "smooth", "long", "hairless"];
const SEVERITY_OPTIONS: AlertSeverity[] = ["low", "medium", "high"];
const SIZE_OPTIONS = ["small", "medium", "large", "xlarge"] as const;
type SizeOption = typeof SIZE_OPTIONS[number];
interface Props {
pet?: Pet;
onSave: (pet: Pet) => void;
onSave: (pet: Pet) => void | Promise<void>;
onCancel: () => void;
saving?: boolean;
saveError?: string | null;
}
export function PetForm({ pet, onSave, onCancel }: Props) {
function newAlert(): Omit<MedicalAlert, "id"> {
return { type: "", description: "", severity: "low" };
}
export function PetForm({ pet, onSave, onCancel, saving, saveError }: Props) {
const [name, setName] = useState(pet?.name ?? "");
const [breed, setBreed] = useState(pet?.breed ?? "");
const [weight, setWeight] = useState(pet?.weight ?? 0);
const [notes, setNotes] = useState(pet?.allergies ?? "");
const [weight, setWeight] = useState(pet?.weightKg ?? 0);
const [notes, setNotes] = useState(pet?.healthAlerts ?? "");
const [coatType, setCoatType] = useState<CoatType | "">((pet?.coatType as CoatType) ?? "");
const [petSizeCategory, setPetSizeCategory] = useState<SizeOption | "">(pet?.petSizeCategory as SizeOption ?? "");
const [preferredCuts, setPreferredCuts] = useState<string[]>(pet?.preferredCuts ?? []);
const [cutInput, setCutInput] = useState("");
const [alerts, setAlerts] = useState<Omit<MedicalAlert, "id">[]>(
pet?.medicalAlerts?.map((alert: MedicalAlert) => ({ type: alert.type, description: alert.description, severity: alert.severity })) ?? []
);
const [alertErrors, setAlertErrors] = useState<Record<number, string>>({});
function addAlert() {
setAlerts(prev => [...prev, newAlert()]);
}
function updateAlert(idx: number, field: keyof Omit<MedicalAlert, "id">, value: string) {
setAlerts(prev => prev.map((a, i) => i === idx ? { ...a, [field]: value } : a));
setAlertErrors(prev => { const e = { ...prev }; delete e[idx]; return e; });
}
function removeAlert(idx: number) {
setAlerts(prev => prev.filter((_, i) => i !== idx));
setAlertErrors(prev => { const e = { ...prev }; delete e[idx]; return e; });
}
function addCut() {
const trimmed = cutInput.trim();
if (trimmed && !preferredCuts.includes(trimmed)) {
setPreferredCuts(prev => [...prev, trimmed]);
}
setCutInput("");
}
function removeCut(cut: string) {
setPreferredCuts(prev => prev.filter(c => c !== cut));
}
function handleCutKey(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") { e.preventDefault(); addCut(); }
}
function validateAlerts(): boolean {
const errors: Record<number, string> = {};
alerts.forEach((a, i) => {
if (!a.type.trim()) errors[i] = "Type is required";
else if (a.severity === "medium" && !a.description.trim()) errors[i] = "Description required at medium/high severity";
});
setAlertErrors(errors);
return Object.keys(errors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!pet) return;
onSave({ ...pet, name, breed, weight, allergies: notes });
if (!handleCutKey) {} // noop reference
if (alerts.length > 0 && !validateAlerts()) return;
const savedPet: Pet = {
...pet,
name,
breed: breed || null,
weightKg: weight || null,
healthAlerts: notes,
coatType: coatType || null,
petSizeCategory: petSizeCategory || null,
preferredCuts,
medicalAlerts: alerts.map((a, i) => ({ ...a, id: pet.medicalAlerts?.[i]?.id ?? crypto.randomUUID() })),
};
onSave(savedPet);
}
const temperamentScore = pet?.temperamentScore;
const temperamentFlags = pet?.temperamentFlags ?? [];
return (
<div className="bg-white rounded-2xl border border-stone-200 p-6 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-stone-800">{pet ? "Edit Pet" : "Add Pet"}</h2>
<h2 className="text-lg font-semibold text-stone-800">{pet?.id ? "Edit Pet" : "Add Pet"}</h2>
<button onClick={onCancel} className="p-2 hover:bg-stone-50 rounded-lg">
<X size={16} className="text-stone-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Name *</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Breed</label>
<input
type="text"
value={breed}
onChange={e => setBreed(e.target.value)}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Weight (kg)</label>
<input
type="number"
value={weight}
onChange={e => setWeight(Number(e.target.value))}
min="0"
step="0.1"
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Notes</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={3}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
</div>
</div>
{/* Coat Type */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Breed</label>
<input
type="text"
value={breed}
onChange={e => setBreed(e.target.value)}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
<label htmlFor="coat-type" className="block text-sm font-medium text-stone-600 mb-1">Coat Type</label>
<select
id="coat-type"
value={coatType}
onChange={e => setCoatType(e.target.value as CoatType)}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent) bg-white"
>
<option value="">Select coat type</option>
{COAT_TYPES.map(ct => (
<option key={ct} value={ct}>{ct.charAt(0).toUpperCase() + ct.slice(1)}</option>
))}
</select>
</div>
{/* Size Category */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Weight (lbs)</label>
<input
type="number"
value={weight}
onChange={e => setWeight(Number(e.target.value))}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
<label htmlFor="size-category" className="block text-sm font-medium text-stone-600 mb-1">Size Category</label>
<select
id="size-category"
value={petSizeCategory}
onChange={e => setPetSizeCategory(e.target.value as SizeOption)}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent) bg-white"
>
<option value="">Select size</option>
{SIZE_OPTIONS.map(s => (
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
</div>
{/* Temperament (read-only) */}
{(temperamentScore != null || temperamentFlags.length > 0) && (
<div className="bg-stone-50 rounded-xl p-4 space-y-2">
<label className="block text-sm font-medium text-stone-500 mb-1">Temperament</label>
{temperamentScore != null && (
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map(s => (
<Star
key={s}
size={14}
className={s <= temperamentScore ? "text-amber-400 fill-amber-400" : "text-stone-300"}
/>
))}
<span className="ml-1 text-xs text-stone-500">({temperamentScore}/5)</span>
</div>
)}
{temperamentFlags.length > 0 && (
<div className="flex flex-wrap gap-1">
{temperamentFlags.map(flag => (
<span key={flag} className="inline-flex items-center px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 text-xs">{flag}</span>
))}
</div>
)}
</div>
)}
{/* Medical Alerts */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Notes</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={3}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-stone-600">Medical Alerts</label>
<button
type="button"
onClick={addAlert}
className="flex items-center gap-1 text-xs text-(--color-accent-dark) font-medium hover:underline"
>
<Plus size={12} /> Add Alert
</button>
</div>
<div className="space-y-3">
{alerts.map((alert, idx) => (
<div key={idx} className="border border-stone-200 rounded-lg p-3 space-y-2">
<div className="flex items-start gap-2">
<div className="flex-1 space-y-2">
<div className="flex gap-2">
<input
type="text"
placeholder="Alert type (e.g. Allergic to chicken)"
value={alert.type}
onChange={e => updateAlert(idx, "type", e.target.value)}
className="flex-1 border border-stone-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
<select
value={alert.severity}
onChange={e => updateAlert(idx, "severity", e.target.value as AlertSeverity)}
className="border border-stone-200 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent) bg-white"
>
{SEVERITY_OPTIONS.map(sev => (
<option key={sev} value={sev}>{sev.charAt(0).toUpperCase() + sev.slice(1)}</option>
))}
</select>
</div>
<textarea
placeholder="Description (optional)"
value={alert.description}
onChange={e => updateAlert(idx, "description", e.target.value)}
rows={2}
className="w-full border border-stone-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
{alertErrors[idx] && (
<p className="text-xs text-red-500">{alertErrors[idx]}</p>
)}
</div>
<button
type="button"
onClick={() => removeAlert(idx)}
className="p-1 hover:bg-stone-100 rounded text-stone-400 mt-0.5"
>
<X size={14} />
</button>
</div>
</div>
))}
{alerts.length === 0 && (
<p className="text-sm text-stone-400">No medical alerts on file.</p>
)}
</div>
</div>
{/* Preferred Cuts */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Preferred Cuts</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={cutInput}
onChange={e => setCutInput(e.target.value)}
onKeyDown={handleCutKey}
placeholder="Type a cut name and press Enter"
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
<button
type="button"
onClick={addCut}
aria-label="Add"
className="px-3 py-2 border border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50"
>
<Plus size={14} />
</button>
</div>
<div className="flex flex-wrap gap-1">
{preferredCuts.map(cut => (
<span key={cut} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-stone-100 text-stone-700 text-xs">
{cut}
<button type="button" onClick={() => removeCut(cut)} className="hover:text-red-500">
<X size={10} />
</button>
</span>
))}
{preferredCuts.length === 0 && <span className="text-xs text-stone-400">None added yet.</span>}
</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50"
disabled={saving}
className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
disabled={saving}
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50"
>
<Save size={14} />
Save
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{saving ? "Saving…" : "Save"}
</button>
</div>
{saveError && (
<p className="text-sm text-red-500 text-center">{saveError}</p>
)}
</form>
</div>
);
}
}
+128 -36
View File
@@ -1,16 +1,7 @@
import { useState, useEffect } from "react";
import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2 } from "lucide-react";
import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2, Star } from "lucide-react";
import { PetForm } from "./PetForm.js";
interface Pet {
id: string;
name: string;
breed: string;
weight: number;
birthDate: string;
photoUrl: string | null;
notes: string | null;
}
import type { Pet } from "@groombook/types";
interface Appointment {
id: string;
@@ -69,14 +60,14 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
throw new Error("Failed to load appointments");
}
const petsData = await petsRes.json();
const petsData: Pet[] = await petsRes.json();
const apptsData: AppointmentsResponse = await apptsRes.json();
setPets(petsData);
setAppointments(apptsData);
if (petsData.length > 0 && !selectedPetId) {
setSelectedPetId(petsData[0].id);
setSelectedPetId(petsData[0]?.id ?? "");
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
@@ -92,19 +83,37 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date());
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
function handlePetSave(updatedPet: Pet) {
setPets(prev => prev.map(p => p.id === updatedPet.id ? updatedPet : p));
setEditingPetId(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
async function handlePetSave(updatedPet: Pet) {
setSaving(true);
setSaveError(null);
try {
const res = await fetch(`/api/portal/pets/${updatedPet.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", ...buildHeaders(sessionId) },
body: JSON.stringify(updatedPet),
});
if (!res.ok) throw new Error("Failed to save pet");
const saved: Pet = await res.json();
setPets(prev => prev.map(p => p.id === saved.id ? saved : p));
setEditingPetId(null);
} catch (e) {
setSaveError(e instanceof Error ? e.message : "Failed to save pet");
} finally {
setSaving(false);
}
}
if (editingPet) {
return (
<PetForm
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pet={editingPet as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSave={handlePetSave as any}
pet={editingPet}
onSave={handlePetSave}
onCancel={() => setEditingPetId(null)}
saving={saving}
saveError={saveError}
/>
);
}
@@ -145,10 +154,10 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
p.id === selectedPetId ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 bg-white hover:border-stone-300"
}`}
>
<span className="text-2xl">{p.photoUrl ? "🐾" : "🐾"}</span>
<span className="text-2xl">{p.photoKey ? "🐾" : "🐾"}</span>
<div className="text-left">
<p className="font-medium text-stone-800 text-sm">{p.name}</p>
<p className="text-xs text-stone-500">{p.breed}</p>
<p className="text-xs text-stone-500">{p.breed ?? "Unknown breed"}</p>
</div>
</button>
))}
@@ -159,17 +168,17 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-2xl bg-(--color-accent-light) flex items-center justify-center text-4xl overflow-hidden">
{selectedPet.photoUrl ? (
<img src={selectedPet.photoUrl} alt={selectedPet.name} className="w-full h-full object-cover" />
{selectedPet.photoKey ? (
<span>🐾</span>
) : (
<span>🐾</span>
)}
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-stone-800">{selectedPet.name}</h2>
<p className="text-stone-500 text-sm">{selectedPet.breed} · {selectedPet.weight} lbs</p>
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {selectedPet.weightKg ? `${selectedPet.weightKg} kg` : "Unknown weight"}</p>
<p className="text-stone-400 text-xs mt-0.5">
Born {selectedPet.birthDate ? new Date(selectedPet.birthDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
Born {selectedPet.dateOfBirth ? new Date(selectedPet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
</p>
</div>
{!readOnly && (
@@ -213,7 +222,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col sm:flex-row sm:items-center py-2.5 border-b border-stone-100 last:border-0">
<span className="text-sm text-stone-500 sm:w-40 shrink-0">{label}</span>
@@ -222,14 +231,59 @@ function InfoRow({ label, value }: { label: string; value: string }) {
);
}
function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
const classes = {
low: "bg-green-100 text-green-700",
medium: "bg-amber-100 text-amber-700",
high: "bg-red-100 text-red-700",
};
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${classes[severity]}`}>
{severity.charAt(0).toUpperCase() + severity.slice(1)}
</span>
);
}
function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
const score = pet.temperamentScore;
const flags = pet.temperamentFlags ?? [];
return (
<div>
<InfoRow label="Name" value={pet.name} />
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
<InfoRow label="Weight" value={`${pet.weight} lbs`} />
<InfoRow label="Date of Birth" value={pet.birthDate ? new Date(pet.birthDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
<InfoRow label="Notes" value={pet.notes || "None"} />
<InfoRow label="Weight" value={pet.weightKg ? `${pet.weightKg} kg` : "Unknown"} />
<InfoRow label="Date of Birth" value={pet.dateOfBirth ? new Date(pet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
{/* Temperament (staff-set, read-only) */}
{(score != null || flags.length > 0) && (
<div className="py-2.5 border-b border-stone-100">
<span className="text-sm text-stone-500 sm:w-40 shrink-0 block mb-1">Temperament</span>
<div className="flex flex-col gap-1.5">
{score != null && (
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map(s => (
<Star
key={s}
size={14}
className={s <= score ? "text-amber-400 fill-amber-400" : "text-stone-300"}
/>
))}
<span className="ml-1 text-xs text-stone-500">({score}/5 · staff-set)</span>
</div>
)}
{flags.length > 0 && (
<div className="flex flex-wrap gap-1">
{flags.map(flag => (
<span key={flag} className="inline-flex items-center px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 text-xs">{flag}</span>
))}
</div>
)}
</div>
</div>
)}
<InfoRow label="Notes" value={pet.healthAlerts || "None"} />
{!readOnly && (
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
Upload Photo
@@ -240,12 +294,30 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
}
function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
const alerts = pet.medicalAlerts ?? [];
return (
<div>
<InfoRow label="Notes" value={pet.notes || "No medical notes on file"} />
<div className="space-y-3">
{alerts.length === 0 ? (
<p className="text-sm text-stone-400">No medical alerts on file.</p>
) : (
alerts.map(alert => (
<div key={alert.id} className="flex items-start gap-3 py-2 border-b border-stone-100 last:border-0">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-stone-800">{alert.type}</span>
<SeverityBadge severity={alert.severity} />
</div>
{alert.description && (
<p className="text-sm text-stone-500">{alert.description}</p>
)}
</div>
</div>
))
)}
{!readOnly && (
<p className="mt-3 text-xs text-stone-400">
Changes to medical notes will be flagged for staff review.
Changes to medical alerts will be flagged for staff review.
</p>
)}
</div>
@@ -253,9 +325,29 @@ function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
}
function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
const coatType = pet.coatType;
const cuts = pet.preferredCuts ?? [];
return (
<div>
<InfoRow label="Notes" value={pet.notes || "No grooming notes on file"} />
<div className="space-y-3">
{coatType && (
<InfoRow
label="Coat Type"
value={<span className="capitalize">{coatType}</span>}
/>
)}
<div className="py-2.5 border-b border-stone-100">
<span className="text-sm text-stone-500 sm:w-40 shrink-0 block mb-1">Preferred Cuts</span>
<div className="flex flex-wrap gap-1">
{cuts.map(cut => (
<span key={cut} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-stone-100 text-stone-700 text-xs">
{cut}
</span>
))}
{cuts.length === 0 && <span className="text-sm text-stone-400">None on file.</span>}
</div>
</div>
<InfoRow label="Grooming Notes" value={pet.groomingNotes || "None"} />
{!readOnly && (
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
Upload Reference Photo
@@ -295,4 +387,4 @@ function HistoryTab({ petHistory }: { petHistory: Appointment[] }) {
)}
</div>
);
}
}