Compare commits

..

14 Commits

Author SHA1 Message Date
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
12 changed files with 1012 additions and 229 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
+78 -7
View File
@@ -142,13 +142,9 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.1 | Client-facing view | Log in as client persona | Customer portal UI displayed |
| TC-WEB-5.12.2 | Appointment list loads | Sign in as `uat-customer@groombook.dev`, open **Appointments** | List of the customer's appointments renders. **No** "Failed to load appointments" error and **no** Retry button. (GRO-2180) |
| TC-WEB-5.12.3 | Date/time display | Inspect each appointment card | Each card shows a human-readable date and time derived from the API `startTime` (e.g. "Mon, Jun 1, 2026" / "10:00 AM"); no `undefined` or blank date/time. (GRO-2180) |
| TC-WEB-5.12.4 | Book New reachable | On the loaded Appointments view (non-readonly), look for the **Book New** button | "Book New" button is visible and opens the booking modal. (GRO-2180) |
| TC-WEB-5.12.5 | Upcoming/Past split | Toggle the **Upcoming** and **Past** tabs | Future appointments appear under Upcoming; completed/cancelled/past appear under Past. (GRO-2180) |
| TC-WEB-5.12.6 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
| TC-WEB-5.12.7 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
| TC-WEB-5.12.8 | Reschedule display | Open **Reschedule** on an upcoming appointment | Summary header shows the current appointment's date and time (from `startTime`); no `undefined`. (GRO-2180) |
| 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 |
### 5.13 Reports UI
@@ -188,6 +184,81 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| 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 |
## 6. Pass/Fail Criteria
**Pass:**
+18
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,14 @@ 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";
+1 -85
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { parseTimeTo24Hour, isUpcoming, getAppointmentStart, formatAppointmentDate, formatAppointmentTime, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
const UPCOMING_APPT = {
id: "appt-1",
@@ -29,26 +29,6 @@ const PAST_APPT = {
status: "completed" as const,
};
// GRO-2180: the /api/portal/appointments contract returns ISO startTime/endTime
// and no date/time fields. These fixtures mirror that shape exactly.
const API_UPCOMING_APPT = {
id: "appt-api-1",
petId: "pet-1",
serviceId: "service-1",
groomerId: null,
startTime: "2099-01-01T10:00:00.000Z",
endTime: "2099-01-01T10:45:00.000Z",
status: "confirmed" as const,
};
const API_PAST_APPT = {
...API_UPCOMING_APPT,
id: "appt-api-2",
startTime: "2020-01-01T10:00:00.000Z",
endTime: "2020-01-01T10:45:00.000Z",
status: "completed" as const,
};
describe("parseTimeTo24Hour", () => {
it("converts AM times correctly", () => {
expect(parseTimeTo24Hour("9:00 AM")).toBe("09:00:00");
@@ -62,13 +42,6 @@ describe("parseTimeTo24Hour", () => {
expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00");
expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00");
});
// GRO-2180 regression: must not throw on undefined/empty input.
it("returns a safe default for missing input", () => {
expect(() => parseTimeTo24Hour(undefined)).not.toThrow();
expect(parseTimeTo24Hour(undefined)).toBe("00:00:00");
expect(parseTimeTo24Hour("")).toBe("00:00:00");
});
});
describe("isUpcoming", () => {
@@ -87,63 +60,6 @@ describe("isUpcoming", () => {
it("returns false for completed appointments", () => {
expect(isUpcoming({ ...UPCOMING_APPT, status: "completed" })).toBe(false);
});
// GRO-2180 regression: the API contract uses ISO startTime with no date/time.
// Previously isUpcoming threw a TypeError on this shape, breaking the page.
it("does not throw on the API startTime/endTime shape", () => {
expect(() => isUpcoming(API_UPCOMING_APPT)).not.toThrow();
expect(() => isUpcoming(API_PAST_APPT)).not.toThrow();
});
it("returns true for future appointments using startTime", () => {
expect(isUpcoming(API_UPCOMING_APPT)).toBe(true);
});
it("returns false for past appointments using startTime", () => {
expect(isUpcoming(API_PAST_APPT)).toBe(false);
});
it("returns false (not throw) when neither startTime nor date is present", () => {
const { startTime, endTime, ...noDate } = API_UPCOMING_APPT;
void startTime;
void endTime;
expect(() => isUpcoming(noDate)).not.toThrow();
expect(isUpcoming(noDate)).toBe(false);
});
});
describe("getAppointmentStart / display helpers (GRO-2180)", () => {
it("resolves the start instant from ISO startTime", () => {
const start = getAppointmentStart(API_UPCOMING_APPT);
expect(start).not.toBeNull();
expect(start?.toISOString()).toBe("2099-01-01T10:00:00.000Z");
});
it("falls back to legacy date + time when startTime is absent", () => {
const start = getAppointmentStart(UPCOMING_APPT);
expect(start).not.toBeNull();
});
it("returns null when there is no usable date", () => {
const { startTime, endTime, ...noDate } = API_UPCOMING_APPT;
void startTime;
void endTime;
expect(getAppointmentStart(noDate)).toBeNull();
});
it("formats date/time without throwing on the API shape", () => {
expect(() => formatAppointmentDate(API_UPCOMING_APPT)).not.toThrow();
expect(() => formatAppointmentTime(API_UPCOMING_APPT)).not.toThrow();
expect(formatAppointmentDate(API_UPCOMING_APPT)).not.toBe("");
expect(formatAppointmentTime(API_UPCOMING_APPT)).not.toBe("");
});
it("returns empty display strings when there is no usable date", () => {
const { startTime, endTime, ...noDate } = API_UPCOMING_APPT;
void startTime;
void endTime;
expect(formatAppointmentDate(noDate)).toBe("");
});
});
describe("CustomerNotesSection", () => {
+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();
});
});
+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>
);
}
+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: "",
});
}}
>
+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 && (
<>
+9 -47
View File
@@ -6,14 +6,8 @@ export interface Appointment {
petId: string;
serviceId: string;
groomerId: string | null;
// The /api/portal/appointments contract returns ISO `startTime`/`endTime`.
// `date`/`time` are the legacy display shape, still produced locally by some
// flows (e.g. test fixtures), so both shapes are optional and code reads
// `startTime` first, falling back to `date` + `time`.
startTime?: string;
endTime?: string;
date?: string;
time?: string;
date: string;
time: string;
status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show';
petName?: string;
serviceName?: string;
@@ -67,8 +61,7 @@ export function formatDate(dateStr: string): string {
});
}
export function parseTimeTo24Hour(time: string | null | undefined): string {
if (!time) return '00:00:00';
export function parseTimeTo24Hour(time: string): string {
const parts = time.split(' ');
const hoursMinutes = parts[0] ?? '';
const period = parts[1] ?? '';
@@ -81,41 +74,10 @@ export function parseTimeTo24Hour(time: string | null | undefined): string {
return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
}
/**
* Resolve an appointment's start instant from either the API contract shape
* (ISO `startTime`) or the legacy `date` + `time` shape. Returns null when no
* usable date is present or the value is unparseable, so callers never throw.
*/
export function getAppointmentStart(appt: Appointment): Date | null {
const raw = appt.startTime
? appt.startTime
: appt.date
? `${appt.date}T${parseTimeTo24Hour(appt.time)}`
: null;
if (!raw) return null;
const parsed = new Date(raw);
return isNaN(parsed.getTime()) ? null : parsed;
}
export function isUpcoming(appt: Appointment): boolean {
const start = getAppointmentStart(appt);
if (!start) return false;
return start > new Date() && appt.status !== 'cancelled' && appt.status !== 'completed';
}
/** Display date string, preferring the ISO `startTime` contract shape. */
export function formatAppointmentDate(appt: Appointment): string {
const start = getAppointmentStart(appt);
return start ? formatDate(start.toISOString()) : '';
}
/** Display time string, preferring the ISO `startTime` contract shape. */
export function formatAppointmentTime(appt: Appointment): string {
const start = getAppointmentStart(appt);
if (start) {
return start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
return appt.time ?? '';
const now = new Date();
const apptDate = new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`);
return apptDate > now && appt.status !== 'cancelled' && appt.status !== 'completed';
}
const STATUS_COLORS: Record<string, string> = {
@@ -326,11 +288,11 @@ function AppointmentCard({
<div className="flex items-center gap-3 text-xs text-stone-500 mt-0.5">
<span className="flex items-center gap-1">
<Calendar size={12} />
{formatAppointmentDate(appt)}
{formatDate(appt.date)}
</span>
<span className="flex items-center gap-1">
<Clock size={12} />
{formatAppointmentTime(appt)}
{appt.time}
</span>
<span>with {appt.groomerName || 'First Available'}</span>
</div>
@@ -684,7 +646,7 @@ export function RescheduleFlow({
{appt.petName || 'Pet'} {appt.serviceName || 'Service'}
</p>
<p className="text-stone-500 mt-0.5">
{formatAppointmentDate(appt)} at {formatAppointmentTime(appt)} with{' '}
{formatDate(appt.date)} at {appt.time} with{' '}
{appt.groomerName || 'First Available'}
</p>
</div>
+273 -37
View File
@@ -1,6 +1,11 @@
import { useState } from "react";
import { X, Save } from "lucide-react";
import type { Pet } from "../mockData.js";
import { X, Save, Plus, Star } 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;
@@ -8,63 +13,294 @@ interface Props {
onCancel: () => void;
}
function newAlert(): Omit<MedicalAlert, "id"> {
return { type: "", description: "", severity: "low" };
}
export function PetForm({ pet, onSave, onCancel }: 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"
@@ -84,4 +320,4 @@ export function PetForm({ pet, onSave, onCancel }: Props) {
</form>
</div>
);
}
}
+105 -33
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");
@@ -100,10 +91,8 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
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)}
/>
);
@@ -145,10 +134,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 +148,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 +202,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 +211,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 +274,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 +305,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 +367,4 @@ function HistoryTab({ petHistory }: { petHistory: Appointment[] }) {
)}
</div>
);
}
}