feat: extract groombook/web from monorepo
- Copy apps/web/ with all src, components, pages, portal - Inline packages/types/ as local packages/types module - Add tsconfig path aliases for @groombook/types - Port Dockerfile and CI workflow - Image name: ghcr.io/groombook/web Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
+415
@@ -0,0 +1,415 @@
|
||||
import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppointmentsPage } from "./pages/Appointments.js";
|
||||
import { ClientsPage } from "./pages/Clients.js";
|
||||
import { ClientDetailPage } from "./pages/ClientDetailPage.js";
|
||||
import { ServicesPage } from "./pages/Services.js";
|
||||
import { StaffPage } from "./pages/Staff.js";
|
||||
import { InvoicesPage } from "./pages/Invoices.js";
|
||||
import { BookPage } from "./pages/Book.js";
|
||||
import { ReportsPage } from "./pages/Reports.js";
|
||||
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
||||
import { SettingsPage } from "./pages/Settings.js";
|
||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||
import { SetupWizard } from "./pages/SetupWizard.tsx";
|
||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
||||
import { GlobalSearch } from "./components/GlobalSearch.js";
|
||||
import { useSession, signIn, signOut } from "./lib/auth-client.js";
|
||||
|
||||
function LoginPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [providers, setProviders] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/providers")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setProviders(data.providers ?? []))
|
||||
.catch(() => setProviders([]));
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const authError = params.get("error");
|
||||
if (authError) setError(authError.replace(/_/g, " "));
|
||||
}, []);
|
||||
|
||||
const handleSocialLogin = async (provider: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await signIn.social({ provider, callbackURL: window.location.origin });
|
||||
if (result?.error) {
|
||||
setError(result.error.message ?? "Sign-in failed");
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isGoogle = providers.includes("google");
|
||||
const isGitHub = providers.includes("github");
|
||||
const isAuthentik = providers.includes("authentik");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#f0f2f5",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "2rem 2.5rem",
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||
textAlign: "center",
|
||||
minWidth: 280,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 22, marginBottom: "0.5rem", color: "#1a202c" }}>GroomBook</h1>
|
||||
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
|
||||
Sign in to continue
|
||||
</p>
|
||||
{error && (
|
||||
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.5rem 0.75rem", marginBottom: "1rem", color: "#991b1b", fontSize: 13 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{isGoogle && (
|
||||
<button
|
||||
onClick={() => handleSocialLogin("google")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #e2e8f0",
|
||||
background: "#fff",
|
||||
color: "#1a202c",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
)}
|
||||
{isGitHub && (
|
||||
<button
|
||||
onClick={() => handleSocialLogin("github")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #e2e8f0",
|
||||
background: "#24292f",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
marginBottom: isAuthentik ? "0.5rem" : 0,
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
)}
|
||||
{isAuthentik && (
|
||||
<button
|
||||
onClick={() => handleSocialLogin("authentik")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: "#4f8a6f",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Redirecting…" : "Sign in with SSO"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ to: "/admin", label: "Appointments" },
|
||||
{ to: "/admin/clients", label: "Clients" },
|
||||
{ to: "/admin/services", label: "Services" },
|
||||
{ to: "/admin/staff", label: "Staff" },
|
||||
{ to: "/admin/invoices", label: "Invoices" },
|
||||
{ to: "/admin/group-bookings", label: "Group Bookings" },
|
||||
{ to: "/admin/reports", label: "Reports" },
|
||||
{ to: "/admin/settings", label: "Settings" },
|
||||
{ to: "/", label: "Customer Portal" },
|
||||
];
|
||||
|
||||
function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { branding } = useBranding();
|
||||
|
||||
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
||||
? `data:${branding.logoMimeType};base64,${branding.logoBase64}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif", background: "#f0f2f5" }}>
|
||||
<nav
|
||||
style={{
|
||||
padding: "0 1.25rem",
|
||||
height: 52,
|
||||
borderBottom: "1px solid #e2e8f0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
background: "#fff",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginRight: "1.25rem",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{logoSrc && (
|
||||
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
|
||||
)}
|
||||
<strong style={{
|
||||
fontSize: 17,
|
||||
color: "#1a202c",
|
||||
letterSpacing: "-0.02em",
|
||||
}}>
|
||||
{branding.businessName}
|
||||
</strong>
|
||||
</div>
|
||||
<GlobalSearch />
|
||||
<div style={{
|
||||
display: "flex",
|
||||
overflowX: "auto",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: "0.25rem",
|
||||
}}>
|
||||
<Link
|
||||
to="/admin/book"
|
||||
style={{
|
||||
padding: "0.4rem 0.85rem",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
background: branding.primaryColor,
|
||||
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Book
|
||||
</Link>
|
||||
{NAV_LINKS.map(({ to, label }) => {
|
||||
const active =
|
||||
to === "/admin"
|
||||
? location.pathname === "/admin"
|
||||
: location.pathname.startsWith(to);
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
style={{
|
||||
padding: "0.4rem 0.75rem",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 600 : 500,
|
||||
color: active ? "#2d6a4f" : "#4b5563",
|
||||
background: active ? "#ecfdf5" : "transparent",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
navigate("/login");
|
||||
}}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: "0.4rem 0.85rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #e2e8f0",
|
||||
background: "#fff",
|
||||
color: "#4b5563",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</nav>
|
||||
<main style={{ padding: "1.25rem 1.5rem" }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppointmentsPage />} />
|
||||
<Route path="/clients" element={<ClientsPage />} />
|
||||
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/staff" element={<StaffPage />} />
|
||||
<Route path="/invoices" element={<InvoicesPage />} />
|
||||
<Route path="/book" element={<BookPage />} />
|
||||
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const location = useLocation();
|
||||
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
||||
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||
const { data: rawSession, isPending: rawSessionLoading } = useSession();
|
||||
// In dev mode (authDisabled=true), session state is irrelevant - skip useSession result
|
||||
const session = authDisabled ? null : rawSession;
|
||||
const sessionLoading = authDisabled ? false : rawSessionLoading;
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/dev/config")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setAuthDisabled(data.authDisabled === true))
|
||||
.catch(() => setAuthDisabled(false));
|
||||
}, []);
|
||||
|
||||
// After session is confirmed, check if setup is needed
|
||||
useEffect(() => {
|
||||
if (authDisabled === null || sessionLoading) return;
|
||||
// Skip if no authenticated session (will redirect to login or dev selector)
|
||||
if (!authDisabled && !session) return;
|
||||
if (authDisabled && !getDevUser()) return;
|
||||
|
||||
fetch("/api/setup/status")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setNeedsSetup(data.needsSetup === true))
|
||||
.catch(() => setNeedsSetup(false));
|
||||
}, [authDisabled, session, sessionLoading]);
|
||||
|
||||
// Public booking redirect pages — no auth or portal chrome needed
|
||||
if (location.pathname === "/booking/confirmed") {
|
||||
return <BookingConfirmedPage />;
|
||||
}
|
||||
if (location.pathname === "/booking/cancelled") {
|
||||
return <BookingCancelledPage />;
|
||||
}
|
||||
if (location.pathname === "/booking/error") {
|
||||
return <BookingErrorPage />;
|
||||
}
|
||||
|
||||
// Setup wizard — standalone, no admin chrome
|
||||
if (location.pathname === "/setup") {
|
||||
return (
|
||||
<BrandingProvider>
|
||||
<SetupWizard onSetupComplete={() => setNeedsSetup(false)} />
|
||||
</BrandingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Still loading auth state or setup check (skip setup check in dev mode)
|
||||
if (authDisabled === null || sessionLoading) return null;
|
||||
|
||||
// Dev mode: show login selector (no setup check needed in dev mode)
|
||||
if (authDisabled && location.pathname === "/login") {
|
||||
return <DevLoginSelector />;
|
||||
}
|
||||
|
||||
// Dev mode: use dev login selector (no setup check needed in dev mode)
|
||||
if (authDisabled && !getDevUser()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users)
|
||||
if (!authDisabled && !session) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
// Production: need setup check
|
||||
if (needsSetup === null) return null;
|
||||
|
||||
// Redirect to setup wizard if needed
|
||||
if (needsSetup) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
// Redirect authenticated users to /admin (but preserve impersonation flow via ?sessionId=)
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId")) {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
|
||||
// Don't render portal chrome at /login — DevLoginSelector is shown instead
|
||||
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login";
|
||||
|
||||
return (
|
||||
<BrandingProvider>
|
||||
{location.pathname.startsWith("/admin") ? (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/admin/*" element={<AdminLayout />} />
|
||||
</Routes>
|
||||
{authDisabled && <DevSessionIndicator />}
|
||||
</>
|
||||
) : showCustomerPortal ? (
|
||||
<>
|
||||
<CustomerPortal />
|
||||
{authDisabled && <DevSessionIndicator />}
|
||||
</>
|
||||
) : null}
|
||||
</BrandingProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
export interface Branding {
|
||||
businessName: string;
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
logoUrl: string | null;
|
||||
logoBase64: string | null;
|
||||
logoMimeType: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_BRANDING: Branding = {
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoUrl: null,
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
};
|
||||
|
||||
const BrandingContext = createContext<{
|
||||
branding: Branding;
|
||||
refresh: () => void;
|
||||
}>({ branding: DEFAULT_BRANDING, refresh: () => {} });
|
||||
|
||||
export function useBranding() {
|
||||
return useContext(BrandingContext);
|
||||
}
|
||||
|
||||
export function BrandingProvider({ children }: { children: React.ReactNode }) {
|
||||
const [branding, setBranding] = useState<Branding>(DEFAULT_BRANDING);
|
||||
const metaThemeColorRef = useRef<HTMLMetaElement | null>(null);
|
||||
|
||||
const fetchBranding = useCallback(() => {
|
||||
fetch("/api/branding")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (data && typeof data.businessName === "string") setBranding(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBranding();
|
||||
}, [fetchBranding]);
|
||||
|
||||
// Apply CSS custom properties whenever branding changes
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty("--color-primary", branding.primaryColor);
|
||||
document.documentElement.style.setProperty("--color-accent", branding.accentColor);
|
||||
// Keep PWA theme-color meta tag in sync with primary color
|
||||
if (!metaThemeColorRef.current) {
|
||||
metaThemeColorRef.current = document.querySelector<HTMLMetaElement>("meta[name='theme-color']");
|
||||
if (!metaThemeColorRef.current) {
|
||||
metaThemeColorRef.current = document.createElement("meta");
|
||||
metaThemeColorRef.current.name = "theme-color";
|
||||
document.head.appendChild(metaThemeColorRef.current);
|
||||
}
|
||||
}
|
||||
metaThemeColorRef.current.content = branding.primaryColor;
|
||||
}, [branding.primaryColor, branding.accentColor]);
|
||||
|
||||
return (
|
||||
<BrandingContext.Provider value={{ branding, refresh: fetchBranding }}>
|
||||
{children}
|
||||
</BrandingContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { App } from "../App";
|
||||
|
||||
|
||||
// Mock fetch to return appropriate responses based on URL
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: false }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/branding") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
} as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
async function renderApp(route = "/admin") {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
// Wait for the config fetch to resolve
|
||||
const nav = await screen.findByRole("navigation");
|
||||
return nav;
|
||||
}
|
||||
|
||||
describe("App navigation", () => {
|
||||
// Use authDisabled=true (dev mode) so nav renders without needing Better Auth useSession() mock
|
||||
beforeEach(() => {
|
||||
localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" }));
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: true }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/branding") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
it("renders the Groom Book brand", async () => {
|
||||
const nav = await renderApp();
|
||||
expect(
|
||||
within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Book CTA button", async () => {
|
||||
const nav = await renderApp();
|
||||
expect(within(nav).getByRole("link", { name: "Book" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all primary nav links", async () => {
|
||||
const nav = await renderApp();
|
||||
const expectedLinks = [
|
||||
"Appointments",
|
||||
"Clients",
|
||||
"Services",
|
||||
"Staff",
|
||||
"Invoices",
|
||||
"Group Bookings",
|
||||
"Reports",
|
||||
];
|
||||
expectedLinks.forEach((label) => {
|
||||
expect(within(nav).getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights the active route link", async () => {
|
||||
const nav = await renderApp("/admin/clients");
|
||||
const clientsLink = within(nav).getByText("Clients");
|
||||
// Active links use fontWeight 600
|
||||
expect(clientsLink).toHaveStyle({ fontWeight: "600" });
|
||||
});
|
||||
|
||||
it("renders customer portal at root", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
// Customer portal should render at root - no admin nav present
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dev login selector", () => {
|
||||
it("redirects to /login when auth is disabled and no user selected", async () => {
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: true }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/dev/users") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
staff: [{ id: "s1", name: "Sarah", email: "sarah@test.com", role: "groomer" }],
|
||||
clients: [{ id: "c1", name: "Client A", email: "a@test.com", petCount: 2 }],
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/branding") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ user: null }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/admin"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should redirect to login selector and show dev login UI
|
||||
await screen.findByText("Dev Login Selector");
|
||||
expect(screen.getByText("Sarah")).toBeInTheDocument();
|
||||
expect(screen.getByText("Client A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not redirect when a dev user is already selected", async () => {
|
||||
localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" }));
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: true }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/branding") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/admin"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show admin nav, not login selector
|
||||
const nav = await screen.findByRole("navigation");
|
||||
expect(
|
||||
within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
|
||||
|
||||
const UPCOMING_APPT = {
|
||||
id: "appt-1",
|
||||
petId: "pet-1",
|
||||
petName: "Buddy",
|
||||
groomerId: "groomer-1",
|
||||
groomerName: "Sarah",
|
||||
services: ["Bath & Brush"],
|
||||
serviceId: "service-1",
|
||||
addOns: [],
|
||||
date: "2027-01-01",
|
||||
time: "10:00 AM",
|
||||
duration: 60,
|
||||
price: 50,
|
||||
status: "confirmed" as const,
|
||||
notes: "",
|
||||
customerNotes: "",
|
||||
confirmationStatus: "pending" as const,
|
||||
};
|
||||
|
||||
const PAST_APPT = {
|
||||
...UPCOMING_APPT,
|
||||
id: "appt-2",
|
||||
date: "2025-01-01",
|
||||
time: "10:00 AM",
|
||||
status: "completed" as const,
|
||||
};
|
||||
|
||||
describe("parseTimeTo24Hour", () => {
|
||||
it("converts AM times correctly", () => {
|
||||
expect(parseTimeTo24Hour("9:00 AM")).toBe("09:00:00");
|
||||
expect(parseTimeTo24Hour("10:00 AM")).toBe("10:00:00");
|
||||
expect(parseTimeTo24Hour("12:00 AM")).toBe("00:00:00");
|
||||
});
|
||||
|
||||
it("converts PM times correctly", () => {
|
||||
expect(parseTimeTo24Hour("1:00 PM")).toBe("13:00:00");
|
||||
expect(parseTimeTo24Hour("2:00 PM")).toBe("14:00:00");
|
||||
expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00");
|
||||
expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUpcoming", () => {
|
||||
it("returns true for future confirmed appointments", () => {
|
||||
expect(isUpcoming(UPCOMING_APPT)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for past appointments", () => {
|
||||
expect(isUpcoming(PAST_APPT)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for cancelled appointments", () => {
|
||||
expect(isUpcoming({ ...UPCOMING_APPT, status: "cancelled" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for completed appointments", () => {
|
||||
expect(isUpcoming({ ...UPCOMING_APPT, status: "completed" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CustomerNotesSection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("renders textarea with existing notes", () => {
|
||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, customerNotes: "Test note" }} sessionId="test-session-id" />);
|
||||
expect(screen.getByRole("textbox")).toHaveValue("Test note");
|
||||
});
|
||||
|
||||
it("renders Save Notes button", () => {
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends Authorization header when session exists", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/notes",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Impersonation-Session-Id": "test-session-id",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send Authorization header when sessionId is null", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId={null} />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/notes",
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
"Authorization": expect.anything(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when save fails", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: "Unauthorized" }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Unauthorized/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows success message when save succeeds", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", customerNotes: "Saved", updatedAt: new Date().toISOString() }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Saved note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Saved!/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables button when notes unchanged", () => {
|
||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, customerNotes: "Existing" }} sessionId="test-session-id" />);
|
||||
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enforces 500 character limit", () => {
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
const longText = "a".repeat(600);
|
||||
fireEvent.change(textarea, { target: { value: longText } });
|
||||
expect(textarea).toHaveValue("a".repeat(500));
|
||||
});
|
||||
|
||||
it("displays character count", () => {
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
expect(screen.getByText(/0\/500/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows exceeded character count in red when limit exceeded", () => {
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
// Type characters one by one to exceed limit
|
||||
const longText = "a".repeat(501);
|
||||
fireEvent.change(textarea, { target: { value: longText } });
|
||||
// The textarea value is truncated to 500, so counter shows 500/500
|
||||
// The class check would need to verify text-red-500 appears
|
||||
// Since the onChange truncates, we test that limit is enforced
|
||||
expect(textarea).toHaveValue("a".repeat(500));
|
||||
});
|
||||
|
||||
it("does not render save button for completed appointments", () => {
|
||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, status: "completed" }} sessionId="test-session-id" />);
|
||||
expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render save button for cancelled appointments", () => {
|
||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, status: "cancelled" }} sessionId="test-session-id" />);
|
||||
expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfirmationSection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
vi.stubGlobal("confirm", vi.fn(() => true));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders pending badge when confirmationStatus is pending", () => {
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
expect(screen.getByText("Pending confirmation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders confirmed badge when confirmationStatus is confirmed", () => {
|
||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
|
||||
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders cancelled badge when confirmationStatus is cancelled", () => {
|
||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "cancelled" }} sessionId="test-session-id" />);
|
||||
expect(screen.getByText("Cancelled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Confirm Appointment button when status is pending", () => {
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
expect(screen.getByRole("button", { name: /Confirm Appointment/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Confirm button when already confirmed", () => {
|
||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
|
||||
expect(screen.queryByRole("button", { name: /Confirm Appointment/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Confirm button when cancelled", () => {
|
||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "cancelled" }} sessionId="test-session-id" />);
|
||||
expect(screen.queryByRole("button", { name: /Confirm Appointment/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls confirm API and updates local status on success", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/confirm",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("sends Authorization header when session exists", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/confirm",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Impersonation-Session-Id": "test-session-id",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send Authorization header when sessionId is null", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId={null} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/confirm",
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
"Authorization": expect.anything(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when confirm API returns 401", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: "Unauthorized" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Unauthorized/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when confirm API returns 403", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ error: "Forbidden" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Forbidden/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when confirm API returns 422 (invalid state)", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: async () => ({ error: "Cannot confirm - appointment is not in pending state" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cannot confirm/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call confirm API if user cancels the confirmation dialog", async () => {
|
||||
vi.stubGlobal("confirm", vi.fn(() => false));
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows loading state while confirming", async () => {
|
||||
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
// Get button reference before clicking
|
||||
const btn = screen.getByRole("button", { name: /Confirm Appointment/i });
|
||||
fireEvent.click(btn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Confirming.../i)).toBeInTheDocument();
|
||||
});
|
||||
// Button is disabled while loading
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows success message briefly after confirm", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Confirmed!/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { BrandingProvider, useBranding } from "../BrandingContext.js";
|
||||
|
||||
function BrandingConsumer() {
|
||||
const { branding } = useBranding();
|
||||
return (
|
||||
<div data-testid="branding">
|
||||
<span data-testid="primary">{branding.primaryColor}</span>
|
||||
<span data-testid="accent">{branding.accentColor}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.documentElement.style.removeProperty("--color-primary");
|
||||
document.documentElement.style.removeProperty("--color-accent");
|
||||
// Remove any theme-color meta tags
|
||||
document.querySelectorAll("meta[name='theme-color']").forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
describe("BrandingProvider", () => {
|
||||
it("applies CSS vars to document root when branding loads", async () => {
|
||||
const branding = {
|
||||
businessName: "Test Salon",
|
||||
primaryColor: "#123456",
|
||||
accentColor: "#654321",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
};
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: async () => branding } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<BrandingProvider>
|
||||
<BrandingConsumer />
|
||||
</BrandingProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue("--color-primary")).toBe("#123456");
|
||||
expect(document.documentElement.style.getPropertyValue("--color-accent")).toBe("#654321");
|
||||
});
|
||||
});
|
||||
|
||||
it("creates and updates meta[name=theme-color]", async () => {
|
||||
const branding = {
|
||||
businessName: "Test Salon",
|
||||
primaryColor: "#abcdef",
|
||||
accentColor: "#fedcba",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
};
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: async () => branding } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<BrandingProvider>
|
||||
<BrandingConsumer />
|
||||
</BrandingProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const meta = document.querySelector<HTMLMetaElement>("meta[name='theme-color']");
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!.content).toBe("#abcdef");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create duplicate meta[name=theme-color] tags on rerender", async () => {
|
||||
const branding = {
|
||||
businessName: "Test Salon",
|
||||
primaryColor: "#111111",
|
||||
accentColor: "#222222",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
};
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: async () => branding } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const { rerender } = render(
|
||||
<BrandingProvider>
|
||||
<BrandingConsumer />
|
||||
</BrandingProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector("meta[name='theme-color']")).not.toBeNull();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<BrandingProvider>
|
||||
<BrandingConsumer />
|
||||
</BrandingProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const metas = document.querySelectorAll("meta[name='theme-color']");
|
||||
expect(metas.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { GlobalSearch } from "../components/GlobalSearch.js";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock("react-router-dom", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-router-dom")>();
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
function renderSearch() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<GlobalSearch />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockNavigate.mockReset();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
describe("GlobalSearch", () => {
|
||||
it("renders the search input with correct aria attributes", () => {
|
||||
renderSearch();
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute("aria-label", "Search clients and pets");
|
||||
expect(input).toHaveAttribute("placeholder", "Search clients & pets…");
|
||||
});
|
||||
|
||||
it("does not fetch when query is empty or whitespace", async () => {
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, " ");
|
||||
// No debounce fires for blank input — verify fetch was never called
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches after debounce and renders client results", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
clients: [{ id: "c1", name: "Alice Johnson", email: "alice@example.com", phone: "555-1234" }],
|
||||
pets: [],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.type(screen.getByRole("combobox"), "Alice");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Alice Johnson")).toBeInTheDocument(), {
|
||||
timeout: 1500,
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining("/api/search?q=Alice"));
|
||||
// Section header should appear
|
||||
expect(screen.getByText("Clients")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fetches after debounce and renders pet results with owner name", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
clients: [],
|
||||
pets: [
|
||||
{ id: "p1", name: "Bella", breed: "Golden Retriever", clientId: "c1", ownerName: "Alice Johnson" },
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.type(screen.getByRole("combobox"), "Bella");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Bella")).toBeInTheDocument(), { timeout: 1500 });
|
||||
expect(screen.getByText("Owner: Alice Johnson")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'No results found' for a query that matches nothing", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ clients: [], pets: [] }),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.type(screen.getByRole("combobox"), "xyzzy");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("No results found")).toBeInTheDocument(), {
|
||||
timeout: 1500,
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to ?highlight=<id> and clears input when a client result is clicked", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
clients: [{ id: "c1", name: "Alice Johnson", email: null, phone: null }],
|
||||
pets: [],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "Alice");
|
||||
|
||||
await waitFor(() => screen.getByText("Alice Johnson"), { timeout: 1500 });
|
||||
await user.click(screen.getByText("Alice Johnson"));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/admin/clients?highlight=c1");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("navigates to owner client ?highlight=<clientId> when a pet result is clicked", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
clients: [],
|
||||
pets: [{ id: "p1", name: "Bella", breed: null, clientId: "c1", ownerName: "Alice" }],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "Bella");
|
||||
|
||||
await waitFor(() => screen.getByText("Bella"), { timeout: 1500 });
|
||||
await user.click(screen.getByText("Bella"));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/admin/clients?highlight=c1");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { ImpersonationBanner } from "../portal/ImpersonationBanner.js";
|
||||
import type { ImpersonationSession } from "@groombook/types";
|
||||
|
||||
function makeSession(overrides: Partial<ImpersonationSession> = {}): ImpersonationSession {
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + 30 * 60 * 1000); // 30 min from now
|
||||
return {
|
||||
id: "session-uuid-1",
|
||||
staffId: "staff-uuid-1",
|
||||
clientId: "client-uuid-1",
|
||||
reason: "Customer requested help",
|
||||
status: "active",
|
||||
startedAt: now.toISOString(),
|
||||
endedAt: null,
|
||||
expiresAt: expires.toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ImpersonationBanner", () => {
|
||||
const onEnd = vi.fn();
|
||||
const onExtend = vi.fn();
|
||||
const onShowAudit = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders banner when session is active", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession()}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/STAFF VIEW/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onEnd when End Session is clicked", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession()}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /end session/i }));
|
||||
expect(onEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onShowAudit when Audit is clicked", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession()}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /audit/i }));
|
||||
expect(onShowAudit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onEnd automatically when session expires", async () => {
|
||||
const expiredSoon = new Date(Date.now() + 500);
|
||||
const session = makeSession({ expiresAt: expiredSoon.toISOString() });
|
||||
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={session}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
|
||||
// Advance past expiry
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(onEnd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Extend button when warning is active and session not yet extended", () => {
|
||||
// Set expiry to 3 min from now — within warning threshold (< 5 min)
|
||||
const expiresAt = new Date(Date.now() + 3 * 60 * 1000).toISOString();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession({ expiresAt })}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
// Tick the timer once to trigger showWarning
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /extend/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Extend button when already extended", () => {
|
||||
const expiresAt = new Date(Date.now() + 3 * 60 * 1000).toISOString();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession({ expiresAt })}
|
||||
isExtended={true}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: /extend/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PetPhotoDisplay", () => {
|
||||
it("shows loading skeleton while fetching", () => {
|
||||
global.fetch = vi.fn(() => new Promise(() => {})) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
expect(screen.getByLabelText("Loading photo…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders photo img when fetch returns a URL", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ url: "https://storage.test/pet-1/photo.jpg" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
const img = await screen.findByRole("img", { name: "Pet photo" });
|
||||
expect(img).toHaveAttribute("src", "https://storage.test/pet-1/photo.jpg");
|
||||
});
|
||||
|
||||
it("shows paw placeholder when API returns 404", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 404 } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("No photo")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByLabelText("Loading photo…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows paw placeholder when fetch rejects (network error)", async () => {
|
||||
global.fetch = vi.fn(() => Promise.reject(new Error("network error"))) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("No photo")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows paw placeholder on non-404 error status", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 500 } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("No photo")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("refetches when petId changes", async () => {
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
const petId = (url as string).match(/\/api\/pets\/([^/]+)\/photo/)?.[1];
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ url: `https://storage.test/${petId}/photo.jpg` }),
|
||||
} as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const { rerender } = render(<PetPhotoDisplay petId="pet-1" />);
|
||||
await screen.findByRole("img");
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-1/photo");
|
||||
|
||||
rerender(<PetPhotoDisplay petId="pet-2" />);
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-2/photo");
|
||||
});
|
||||
});
|
||||
|
||||
it("applies custom size prop to container", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 404 } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const { container } = render(<PetPhotoDisplay petId="pet-1" size={96} />);
|
||||
|
||||
await screen.findByLabelText("No photo");
|
||||
const div = container.firstChild as HTMLElement;
|
||||
expect(div).toHaveStyle({ width: "96px", height: "96px" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
|
||||
|
||||
// ── XHR mock ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface XhrMock {
|
||||
upload: { addEventListener: ReturnType<typeof vi.fn> };
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
open: ReturnType<typeof vi.fn>;
|
||||
setRequestHeader: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
status: number;
|
||||
// Callbacks stored by the mock so tests can trigger them
|
||||
_triggerLoad: () => void;
|
||||
_triggerError: () => void;
|
||||
_triggerProgress: (loaded: number, total: number) => void;
|
||||
}
|
||||
|
||||
function makeXhrMock(status = 200): XhrMock {
|
||||
const uploadListeners: Record<string, (ev: ProgressEvent) => void> = {};
|
||||
const listeners: Record<string, () => void> = {};
|
||||
|
||||
const mock: XhrMock = {
|
||||
upload: {
|
||||
addEventListener: vi.fn((event: string, cb: (ev: ProgressEvent) => void) => {
|
||||
uploadListeners[event] = cb;
|
||||
}),
|
||||
},
|
||||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||
listeners[event] = cb;
|
||||
}),
|
||||
open: vi.fn(),
|
||||
setRequestHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
status,
|
||||
_triggerLoad: () => listeners["load"]?.(),
|
||||
_triggerError: () => listeners["error"]?.(),
|
||||
_triggerProgress: (loaded, total) =>
|
||||
uploadListeners["progress"]?.({ lengthComputable: true, loaded, total } as ProgressEvent),
|
||||
};
|
||||
return mock;
|
||||
}
|
||||
|
||||
// ── Canvas mock ───────────────────────────────────────────────────────────────
|
||||
|
||||
// jsdom doesn't implement canvas — provide a minimal stub
|
||||
function mockCanvas(blob: Blob) {
|
||||
const ctx = { drawImage: vi.fn() };
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
|
||||
if (tag === "canvas") {
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ctx,
|
||||
toBlob: (cb: (b: Blob | null) => void) => cb(blob),
|
||||
};
|
||||
return canvas as unknown as HTMLCanvasElement;
|
||||
}
|
||||
return originalCreateElement(tag);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Image mock ────────────────────────────────────────────────────────────────
|
||||
|
||||
function mockImage(width = 800, height = 600) {
|
||||
const originalImage = globalThis.Image;
|
||||
const ImageMock = vi.fn().mockImplementation(() => {
|
||||
const img = {
|
||||
width,
|
||||
height,
|
||||
onload: null as (() => void) | null,
|
||||
onerror: null as (() => void) | null,
|
||||
set src(_v: string) {
|
||||
// trigger onload asynchronously
|
||||
setTimeout(() => img.onload?.(), 0);
|
||||
},
|
||||
};
|
||||
return img;
|
||||
});
|
||||
globalThis.Image = ImageMock as unknown as typeof Image;
|
||||
return () => {
|
||||
globalThis.Image = originalImage;
|
||||
};
|
||||
}
|
||||
|
||||
// ── URL mock ──────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
URL.createObjectURL = vi.fn(() => "blob:mock");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeFile(type = "image/jpeg", name = "photo.jpg", sizeBytes = 1024): File {
|
||||
const buf = new Uint8Array(sizeBytes);
|
||||
return new File([buf], name, { type });
|
||||
}
|
||||
|
||||
function selectFile(file: File) {
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
Object.defineProperty(input, "files", { value: [file], configurable: true });
|
||||
fireEvent.change(input);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PetPhotoUpload", () => {
|
||||
it("renders the upload button in idle state", () => {
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /upload photo/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows an error for an unsupported file type", async () => {
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("text/plain", "doc.txt"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/JPEG, PNG, WebP, or GIF/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the button while uploading", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
let xhrInstance: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(200);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.jpg" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
// Button should become disabled during upload
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("calls onUploaded and resets after successful upload", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
let xhrInstance!: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(200);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
const onUploaded = vi.fn();
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if ((url as string).includes("upload-url")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.jpg" }),
|
||||
} as Response);
|
||||
}
|
||||
// confirm
|
||||
return Promise.resolve({ ok: true, json: async () => ({ ok: true }) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={onUploaded} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
// Wait for XHR to be set up, then trigger load
|
||||
await waitFor(() => expect(xhrInstance).toBeDefined());
|
||||
xhrInstance._triggerLoad();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUploaded).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("shows error message when upload-url request fails", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
json: async () => ({ error: "Pet not found" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Pet not found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("shows error message when XHR upload fails", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
let xhrInstance!: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(0);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.jpg" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
await waitFor(() => expect(xhrInstance).toBeDefined());
|
||||
xhrInstance._triggerError();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/network error/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("shows upload progress percentage", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
let xhrInstance!: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(200);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.jpg" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
await waitFor(() => expect(xhrInstance).toBeDefined());
|
||||
xhrInstance._triggerProgress(50, 100);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Uploading 50%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("skips canvas resize for GIF files", async () => {
|
||||
const createElementSpy = vi.spyOn(document, "createElement");
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.gif" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
let xhrInstance!: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(200);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/gif", "anim.gif", 512));
|
||||
|
||||
// Wait for XHR to be invoked
|
||||
await waitFor(() => expect(xhrInstance).toBeDefined());
|
||||
|
||||
// canvas should NOT have been created for GIF
|
||||
const canvasCalls = createElementSpy.mock.calls.filter(([tag]) => tag === "canvas");
|
||||
expect(canvasCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { ImpersonationBanner } from "../portal/ImpersonationBanner.js";
|
||||
import { AuditLogViewer } from "../portal/AuditLogViewer.js";
|
||||
import type { ImpersonationSession, ImpersonationAuditLog } from "@groombook/types";
|
||||
|
||||
const SESSION: ImpersonationSession = {
|
||||
id: "sess-1",
|
||||
staffId: "staff-1",
|
||||
clientId: "client-1",
|
||||
reason: "Customer reported missing appointment",
|
||||
status: "active",
|
||||
startedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
endedAt: null,
|
||||
expiresAt: new Date(Date.now() + 25 * 60_000).toISOString(),
|
||||
createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
};
|
||||
|
||||
const AUDIT_LOGS: ImpersonationAuditLog[] = [
|
||||
{
|
||||
id: "log-1",
|
||||
sessionId: "sess-1",
|
||||
action: "session_started",
|
||||
pageVisited: null,
|
||||
metadata: { reason: "Customer reported missing appointment" },
|
||||
createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "log-2",
|
||||
sessionId: "sess-1",
|
||||
action: "page_view",
|
||||
pageVisited: "appointments",
|
||||
metadata: null,
|
||||
createdAt: new Date(Date.now() - 3 * 60_000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
// ─── ImpersonationBanner ────────────────────────────────────────────────────
|
||||
|
||||
describe("ImpersonationBanner", () => {
|
||||
it("renders STAFF VIEW label", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("STAFF VIEW")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the session reason", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Customer reported missing appointment/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onEnd when End Session is clicked", () => {
|
||||
const onEnd = vi.fn();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /End Session/i }));
|
||||
expect(onEnd).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onShowAudit when Audit is clicked", () => {
|
||||
const onShowAudit = vi.fn();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Audit/i }));
|
||||
expect(onShowAudit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows Extend button when less than 5 minutes remain and not yet extended", async () => {
|
||||
const nearlyExpiredSession: ImpersonationSession = {
|
||||
...SESSION,
|
||||
expiresAt: new Date(Date.now() + 3 * 60_000).toISOString(), // 3 min left
|
||||
};
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={nearlyExpiredSession}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Extend/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show Extend button when already extended", async () => {
|
||||
const nearlyExpiredSession: ImpersonationSession = {
|
||||
...SESSION,
|
||||
expiresAt: new Date(Date.now() + 3 * 60_000).toISOString(),
|
||||
};
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={nearlyExpiredSession}
|
||||
isExtended={true}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /Extend/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AuditLogViewer ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("AuditLogViewer", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("fetches and displays audit log entries", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [...AUDIT_LOGS].reverse(), // API returns newest-first
|
||||
} as Response);
|
||||
|
||||
render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// "session started" appears in both the filter dropdown option and the log entry span
|
||||
expect(screen.getAllByText("session started").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
expect(screen.getByText("appointments")).toBeInTheDocument();
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/impersonation/sessions/sess-1/audit-log");
|
||||
});
|
||||
|
||||
it("shows error state when fetch fails", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
} as Response);
|
||||
|
||||
render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load audit log/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText(/Loading audit log/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose when X button is clicked", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
} as Response);
|
||||
|
||||
const onClose = vi.fn();
|
||||
render(<AuditLogViewer sessionId="sess-1" onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No audit entries/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "" }));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("filters entries by action type", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [...AUDIT_LOGS].reverse(),
|
||||
} as Response);
|
||||
|
||||
render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("session started").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Filter to page_view only
|
||||
const select = screen.getByRole("combobox");
|
||||
fireEvent.change(select, { target: { value: "page_view" } });
|
||||
|
||||
expect(screen.getByText("appointments")).toBeInTheDocument();
|
||||
// After filtering, the "session started" span (log entry) should be gone
|
||||
// The option in the select still has the text but the log entry span does not
|
||||
const spans = document.querySelectorAll("span.inline-block");
|
||||
expect(Array.from(spans).every((s) => s.textContent !== "session started")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CustomerPortal — session loading ──────────────────────────────────────
|
||||
|
||||
describe("CustomerPortal session loading", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/branding") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url.startsWith("/api/impersonation/sessions/")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => SESSION,
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
it("loads and displays impersonation banner when sessionId is in URL", async () => {
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/?sessionId=sess-1"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Wait for the session fetch and banner to appear
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/impersonation/sessions/sess-1");
|
||||
});
|
||||
// Banner "End Session" button is unique to the active impersonation banner
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /End Session/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show banner when no sessionId in URL", async () => {
|
||||
vi.mocked(global.fetch).mockClear();
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// No impersonation session fetch should happen
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const impersonationFetches = vi.mocked(global.fetch).mock.calls.filter(
|
||||
([url]) => typeof url === "string" && url.startsWith("/api/impersonation/")
|
||||
);
|
||||
expect(impersonationFetches).toHaveLength(0);
|
||||
expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to /admin/clients after ending impersonation session", async () => {
|
||||
// Mock window.location.href
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/?sessionId=sess-1"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Wait for banner to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /End Session/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click "End Session" — this triggers handleEnd which calls the API then redirects
|
||||
fireEvent.click(screen.getByRole("button", { name: /End Session/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/admin/clients");
|
||||
});
|
||||
|
||||
// Restore
|
||||
Object.defineProperty(window, "location", { value: originalLocation, writable: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Calendar, RefreshCw, Trash2, Copy, Check } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
staffId: string;
|
||||
staffName: string;
|
||||
}
|
||||
|
||||
export function CalendarSyncSection({ staffId }: Props) {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<"generate" | "revoke" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchToken();
|
||||
}, [staffId]);
|
||||
|
||||
async function fetchToken() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/staff/${staffId}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch staff data");
|
||||
const data = await res.json();
|
||||
setToken(data.icalToken || null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateToken() {
|
||||
setActionLoading("generate");
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/staff/${staffId}/ical-token`, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to generate token");
|
||||
}
|
||||
const data = await res.json();
|
||||
setToken(data.icalToken);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to generate token");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeToken() {
|
||||
if (!showRevokeConfirm) {
|
||||
setShowRevokeConfirm(true);
|
||||
return;
|
||||
}
|
||||
setActionLoading("revoke");
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/staff/${staffId}/ical-token`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to revoke token");
|
||||
}
|
||||
setToken(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to revoke token");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
setShowRevokeConfirm(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyFeedUrl() {
|
||||
if (!token) return;
|
||||
const url = `${window.location.origin}/api/calendar/${staffId}.ics?token=${token}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
const feedUrl = token ? `/api/calendar/${staffId}.ics?token=${token}` : null;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Calendar size={18} className="text-(--color-accent)" />
|
||||
<h3 className="font-medium text-stone-800">Calendar Sync</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-stone-500 mb-4">
|
||||
Generate a calendar feed link to share your upcoming appointments with any calendar app that supports iCal (Apple Calendar, Google Calendar, Outlook).
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-sm text-stone-400">Loading...</div>
|
||||
) : token ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-500 mb-1">Your Calendar Feed URL</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={feedUrl ?? ""}
|
||||
className="flex-1 text-sm border border-stone-200 rounded-lg px-3 py-2 bg-stone-50 text-stone-600 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={copyFeedUrl}
|
||||
className="flex items-center gap-1.5 px-3 py-2 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50"
|
||||
title="Copy link"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-600" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRevokeConfirm ? (
|
||||
<div className="flex items-center gap-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="flex-1 text-sm text-red-700">
|
||||
Revoke your calendar feed link? Anyone with the current link will lose access.
|
||||
</p>
|
||||
<button
|
||||
onClick={revokeToken}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "revoke" ? (
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
Revoke
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRevokeConfirm(false)}
|
||||
disabled={actionLoading !== null}
|
||||
className="px-3 py-1.5 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={generateToken}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "generate" ? (
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Regenerate
|
||||
</button>
|
||||
<button
|
||||
onClick={revokeToken}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex items-center gap-1.5 px-3 py-2 border border-red-200 rounded-lg text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "revoke" ? (
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-stone-400">
|
||||
Regenerating will create a new URL and invalidate the old one.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-stone-600">You don't have a calendar feed set up yet.</p>
|
||||
<button
|
||||
onClick={generateToken}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex items-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"
|
||||
>
|
||||
{actionLoading === "generate" ? (
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Calendar size={14} />
|
||||
)}
|
||||
{actionLoading === "generate" ? "Generating..." : "Generate Calendar Feed"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||
|
||||
export function DevSessionIndicator() {
|
||||
const user = getDevUser();
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "#1a202c",
|
||||
color: "#e2e8f0",
|
||||
padding: "0.4rem 1rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.75rem",
|
||||
fontSize: 12,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Dev mode: acting as <strong>{user.name}</strong> ({user.type})
|
||||
</span>
|
||||
<Link
|
||||
to="/login"
|
||||
style={{
|
||||
color: "var(--color-primary)",
|
||||
textDecoration: "underline",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Switch user
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface ClientResult {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
interface PetResult {
|
||||
id: string;
|
||||
name: string;
|
||||
breed: string | null;
|
||||
clientId: string;
|
||||
ownerName: string;
|
||||
}
|
||||
|
||||
interface SearchResults {
|
||||
clients: ClientResult[];
|
||||
pets: PetResult[];
|
||||
}
|
||||
|
||||
export function GlobalSearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setResults(null);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
||||
if (res.ok) {
|
||||
const data: SearchResults = await res.json();
|
||||
setResults(data);
|
||||
setOpen(true);
|
||||
} else {
|
||||
setError("Search failed. Please try again.");
|
||||
}
|
||||
} catch {
|
||||
setError("Search failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(e.target as Node) &&
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
function handleClientClick(client: ClientResult) {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
navigate(`/admin/clients?highlight=${client.id}`);
|
||||
}
|
||||
|
||||
function handlePetClick(pet: PetResult) {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
navigate(`/admin/clients?highlight=${pet.clientId}`);
|
||||
}
|
||||
|
||||
const hasResults = results && (results.clients.length > 0 || results.pets.length > 0);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", flex: "1 1 0", maxWidth: 320, minWidth: 0 }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<Search
|
||||
size={15}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 10,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "#9ca3af",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
placeholder="Search clients & pets…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => results && setOpen(true)}
|
||||
style={{
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
height: 44,
|
||||
paddingLeft: 32,
|
||||
paddingRight: 12,
|
||||
fontSize: 13,
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: 8,
|
||||
outline: "none",
|
||||
background: "#f8fafc",
|
||||
color: "#1a202c",
|
||||
}}
|
||||
aria-label="Search clients and pets"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
role="listbox"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 4px)",
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "#fff",
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: 10,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.10)",
|
||||
zIndex: 100,
|
||||
overflow: "hidden",
|
||||
minWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
|
||||
Searching…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "#dc2626" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !hasResults && (
|
||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results && results.clients.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 16px 4px",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "#9ca3af",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
}}
|
||||
>
|
||||
Clients
|
||||
</div>
|
||||
{results.clients.map((client) => (
|
||||
<button
|
||||
key={client.id}
|
||||
role="option"
|
||||
onClick={() => handleClientClick(client)}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
padding: "12px 16px",
|
||||
minHeight: 48,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "#f8fafc";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: "#1a202c" }}>
|
||||
{client.name}
|
||||
</span>
|
||||
{client.phone && (
|
||||
<span style={{ fontSize: 12, color: "#6b7280", marginTop: 1 }}>
|
||||
{client.phone}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results && results.pets.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 16px 4px",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "#9ca3af",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
}}
|
||||
>
|
||||
Pets
|
||||
</div>
|
||||
{results.pets.map((pet) => (
|
||||
<button
|
||||
key={pet.id}
|
||||
role="option"
|
||||
onClick={() => handlePetClick(pet)}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
padding: "12px 16px",
|
||||
minHeight: 48,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "#f8fafc";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: "#1a202c" }}>
|
||||
{pet.name}
|
||||
{pet.breed && (
|
||||
<span style={{ fontWeight: 400, color: "#4b5563" }}> · {pet.breed}</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "#6b7280", marginTop: 1 }}>
|
||||
Owner: {pet.ownerName}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
petId: string;
|
||||
/** Size of the photo avatar in pixels. Default: 64. */
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type PhotoState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; url: string }
|
||||
| { status: "none" }
|
||||
| { status: "error" };
|
||||
|
||||
/**
|
||||
* Fetches and displays a pet's photo from the API.
|
||||
* Shows a loading skeleton while fetching, a paw-print placeholder when no photo exists,
|
||||
* and gracefully falls back to the placeholder on error.
|
||||
*/
|
||||
export function PetPhotoDisplay({ petId, size = 64, className }: Props) {
|
||||
const [state, setState] = useState<PhotoState>({ status: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
setState({ status: "loading" });
|
||||
fetch(`/api/pets/${petId}/photo`)
|
||||
.then(async (res) => {
|
||||
if (res.status === 404) {
|
||||
setState({ status: "none" });
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { url: string };
|
||||
setState({ status: "loaded", url: data.url });
|
||||
})
|
||||
.catch(() => setState({ status: "error" }));
|
||||
}, [petId]);
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: Math.round(size * 0.2),
|
||||
overflow: "hidden",
|
||||
background: "#f0ebe4",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
...containerStyle,
|
||||
background: "linear-gradient(90deg, #f0ebe4 25%, #e8e0d8 50%, #f0ebe4 75%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer 1.5s infinite",
|
||||
}}
|
||||
aria-label="Loading photo…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "loaded") {
|
||||
return (
|
||||
<div className={className} style={containerStyle}>
|
||||
<img
|
||||
src={state.url}
|
||||
alt="Pet photo"
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// no photo / error — paw placeholder
|
||||
return (
|
||||
<div className={className} style={containerStyle} aria-label="No photo">
|
||||
<span style={{ fontSize: Math.round(size * 0.45), userSelect: "none" }}>🐾</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
petId: string;
|
||||
/** Called after a successful upload so the parent can refresh the display. */
|
||||
onUploaded: () => void;
|
||||
}
|
||||
|
||||
const MAX_DIMENSION = 1200;
|
||||
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
|
||||
/**
|
||||
* Client-side-resize-then-upload component.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User selects a file
|
||||
* 2. Component resizes to max 1200px on the longest side (canvas)
|
||||
* 3. Requests a presigned PUT URL from the API
|
||||
* 4. PUTs the resized blob directly to object storage
|
||||
* 5. Confirms upload with the API (records the key in DB)
|
||||
*/
|
||||
export function PetPhotoUpload({ petId, onUploaded }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [state, setState] = useState<
|
||||
| { status: "idle" }
|
||||
| { status: "resizing" }
|
||||
| { status: "uploading"; progress: number }
|
||||
| { status: "confirming" }
|
||||
| { status: "done" }
|
||||
| { status: "error"; message: string }
|
||||
>({ status: "idle" });
|
||||
|
||||
async function resizeImage(file: File): Promise<{ blob: Blob; contentType: string }> {
|
||||
// GIFs must bypass canvas resize — canvas destroys animation frames
|
||||
if (file.type === "image/gif") {
|
||||
return { blob: file, contentType: "image/gif" };
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const { width, height } = img;
|
||||
const scale =
|
||||
Math.max(width, height) > MAX_DIMENSION
|
||||
? MAX_DIMENSION / Math.max(width, height)
|
||||
: 1;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(width * scale);
|
||||
canvas.height = Math.round(height * scale);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return reject(new Error("Canvas not supported"));
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
const contentType = file.type === "image/png" ? "image/png" : "image/jpeg";
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return reject(new Error("Failed to encode image"));
|
||||
resolve({ blob, contentType });
|
||||
},
|
||||
contentType,
|
||||
0.85
|
||||
);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("Failed to load image"));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setState({ status: "error", message: "File exceeds 50MB limit. Please choose a smaller image." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ status: "resizing" });
|
||||
|
||||
let blob: Blob;
|
||||
let contentType: string;
|
||||
try {
|
||||
({ blob, contentType } = await resizeImage(file));
|
||||
} catch (e) {
|
||||
setState({ status: "error", message: e instanceof Error ? e.message : "Image resize failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get presigned upload URL
|
||||
setState({ status: "uploading", progress: 0 });
|
||||
let uploadUrl: string;
|
||||
let key: string;
|
||||
try {
|
||||
const res = await fetch(`/api/pets/${petId}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType, fileSizeBytes: blob.size }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { uploadUrl: string; key: string };
|
||||
uploadUrl = data.uploadUrl;
|
||||
key = data.key;
|
||||
} catch (e) {
|
||||
setState({ status: "error", message: e instanceof Error ? e.message : "Failed to get upload URL" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload directly to object storage
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
xhr.upload.addEventListener("progress", (ev) => {
|
||||
if (ev.lengthComputable) {
|
||||
setState({ status: "uploading", progress: Math.round((ev.loaded / ev.total) * 100) });
|
||||
}
|
||||
});
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new Error(`Upload failed: HTTP ${xhr.status}`));
|
||||
});
|
||||
xhr.addEventListener("error", () => reject(new Error("Upload failed: network error")));
|
||||
xhr.open("PUT", uploadUrl);
|
||||
xhr.setRequestHeader("Content-Type", contentType);
|
||||
xhr.send(blob);
|
||||
});
|
||||
} catch (e) {
|
||||
setState({ status: "error", message: e instanceof Error ? e.message : "Upload failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm with API
|
||||
setState({ status: "confirming" });
|
||||
try {
|
||||
const res = await fetch(`/api/pets/${petId}/photo/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setState({ status: "error", message: e instanceof Error ? e.message : "Failed to confirm upload" });
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ status: "done" });
|
||||
onUploaded();
|
||||
|
||||
// Reset after a moment
|
||||
setTimeout(() => setState({ status: "idle" }), 2000);
|
||||
}
|
||||
|
||||
const busy = state.status === "resizing" || state.status === "uploading" || state.status === "confirming";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_TYPES.join(",")}
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
// reset so re-selecting same file works
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: "0.2rem 0.55rem",
|
||||
borderRadius: 5,
|
||||
border: "1px solid #d1d5db",
|
||||
background: "#fff",
|
||||
cursor: busy ? "not-allowed" : "pointer",
|
||||
color: busy ? "#9ca3af" : "#374151",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.3rem",
|
||||
}}
|
||||
>
|
||||
{state.status === "idle" && "📷 Upload photo"}
|
||||
{state.status === "resizing" && "Resizing…"}
|
||||
{state.status === "uploading" && `Uploading ${state.progress}%`}
|
||||
{state.status === "confirming" && "Saving…"}
|
||||
{state.status === "done" && "✓ Uploaded"}
|
||||
{state.status === "error" && "📷 Upload photo"}
|
||||
</button>
|
||||
{state.status === "error" && (
|
||||
<div style={{ fontSize: 11, color: "#dc2626", marginTop: "0.2rem" }}>
|
||||
{state.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--color-primary: #4f8a6f;
|
||||
--color-primary-dark: color-mix(in srgb, var(--color-primary) 80%, #000);
|
||||
--color-accent: #8b7355;
|
||||
--color-accent-hover: color-mix(in srgb, var(--color-accent) 88%, #000);
|
||||
--color-accent-dark: color-mix(in srgb, var(--color-accent) 78%, #000);
|
||||
--color-accent-light: color-mix(in srgb, var(--color-accent) 18%, #fff);
|
||||
--color-accent-lighter: color-mix(in srgb, var(--color-accent) 9%, #fff);
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: #1a202c;
|
||||
background: #f0f2f5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-top: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h2, h3, h4 {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* ─── Admin button polish ─── */
|
||||
button {
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
/* ─── Admin input / select focus states ─── */
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Admin card-like containers (borders get subtle shadow) ─── */
|
||||
[style*="border: 1px solid"] {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ─── Scrollbar polish ─── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||
});
|
||||
|
||||
export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
|
||||
/**
|
||||
* Patches global fetch to include X-Dev-User-Id header on API requests
|
||||
* when a dev user is selected via the login selector.
|
||||
*
|
||||
* Intentionally mutates window.fetch — this is dev-only (AUTH_DISABLED=true).
|
||||
*/
|
||||
export function installDevFetchInterceptor() {
|
||||
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
|
||||
const user = getDevUser();
|
||||
if (!user) return originalFetch(input, init);
|
||||
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
|
||||
|
||||
// Only inject header for API calls
|
||||
if (!url.startsWith("/api/")) return originalFetch(input, init);
|
||||
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("X-Dev-User-Id")) {
|
||||
headers.set("X-Dev-User-Id", user.id);
|
||||
}
|
||||
|
||||
return originalFetch(input, { ...init, headers });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App.js";
|
||||
import { installDevFetchInterceptor } from "./lib/devFetch.js";
|
||||
import "./index.css";
|
||||
|
||||
installDevFetchInterceptor();
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("Root element not found");
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,957 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function startOfWeek(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay(); // 0=Sun
|
||||
const diff = day === 0 ? -6 : 1 - day; // Monday start
|
||||
d.setDate(d.getDate() + diff);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function addDays(date: Date, n: number): Date {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + n);
|
||||
return d;
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function fmtDateShort(d: Date): string {
|
||||
return d.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
scheduled: "#3b82f6",
|
||||
confirmed: "#10b981",
|
||||
in_progress: "#f59e0b",
|
||||
completed: "#6b7280",
|
||||
cancelled: "#ef4444",
|
||||
no_show: "#9ca3af",
|
||||
};
|
||||
|
||||
const GROOMER_PALETTE = [
|
||||
"#8b5cf6", // violet
|
||||
"#0ea5e9", // sky
|
||||
"#f43f5e", // rose
|
||||
"#14b8a6", // teal
|
||||
"#f97316", // orange
|
||||
"#a855f7", // purple
|
||||
"#84cc16", // lime
|
||||
"#e879f9", // fuchsia
|
||||
];
|
||||
const UNASSIGNED_COLOR = "#94a3b8";
|
||||
|
||||
const STATUS_TRANSITIONS: Record<string, string[]> = {
|
||||
scheduled: ["confirmed", "cancelled", "no_show"],
|
||||
confirmed: ["in_progress", "cancelled", "no_show"],
|
||||
in_progress: ["completed", "no_show"],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
no_show: [],
|
||||
};
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type CascadeMode = "this_only" | "this_and_future" | "all";
|
||||
|
||||
interface BookingForm {
|
||||
clientId: string;
|
||||
petId: string;
|
||||
serviceId: string;
|
||||
staffId: string;
|
||||
batherStaffId: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
notes: string;
|
||||
recurring: boolean;
|
||||
recurrenceFrequencyWeeks: string;
|
||||
recurrenceCount: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: BookingForm = {
|
||||
clientId: "",
|
||||
petId: "",
|
||||
serviceId: "",
|
||||
staffId: "",
|
||||
batherStaffId: "",
|
||||
date: formatDate(new Date()),
|
||||
startTime: "09:00",
|
||||
notes: "",
|
||||
recurring: false,
|
||||
recurrenceFrequencyWeeks: "4",
|
||||
recurrenceCount: "12",
|
||||
};
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function AppointmentsPage() {
|
||||
const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date()));
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [staff, setStaff] = useState<Staff[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState<BookingForm>(EMPTY_FORM);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
|
||||
// Groomer view state
|
||||
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
||||
// 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 weekEnd = addDays(weekStart, 6);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/invoices/stats/summary")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => { if (data) setPaymentStats(data); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loadAppointments = useCallback(() => {
|
||||
const from = weekStart.toISOString();
|
||||
const to = addDays(weekStart, 7).toISOString();
|
||||
return fetch(`/api/appointments?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Appointment[]>;
|
||||
})
|
||||
.then(setAppointments);
|
||||
}, [weekStart]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([
|
||||
loadAppointments(),
|
||||
fetch("/api/clients").then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Client[]>;
|
||||
}).then(setClients),
|
||||
fetch("/api/services").then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Service[]>;
|
||||
}).then(setServices),
|
||||
fetch("/api/staff").then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Staff[]>;
|
||||
}).then(setStaff),
|
||||
])
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [loadAppointments]);
|
||||
|
||||
// Load pets when client is selected
|
||||
useEffect(() => {
|
||||
if (!form.clientId) {
|
||||
setPets([]);
|
||||
setForm((f) => ({ ...f, petId: "" }));
|
||||
return;
|
||||
}
|
||||
fetch(`/api/pets?clientId=${encodeURIComponent(form.clientId)}`)
|
||||
.then((r) => r.json() as Promise<Pet[]>)
|
||||
.then(setPets);
|
||||
}, [form.clientId]);
|
||||
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||
|
||||
// Assign a stable color to each active groomer by index
|
||||
const activeGroomers = staff.filter((s) => s.active && s.role === "groomer");
|
||||
const groomerColorMap = new Map<string, string>(
|
||||
activeGroomers.map((s, i) => [s.id, GROOMER_PALETTE[i % GROOMER_PALETTE.length] ?? UNASSIGNED_COLOR])
|
||||
);
|
||||
|
||||
function groomerColor(staffId: string | null): string {
|
||||
if (!staffId) return UNASSIGNED_COLOR;
|
||||
return groomerColorMap.get(staffId) ?? UNASSIGNED_COLOR;
|
||||
}
|
||||
|
||||
function apptColor(a: Appointment): string {
|
||||
return viewMode === "groomer" ? groomerColor(a.staffId) : (STATUS_COLORS[a.status] ?? "#94a3b8");
|
||||
}
|
||||
|
||||
function toggleGroomer(key: string | null) {
|
||||
setHiddenGroomers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const apptsByDay = days.map((day) => {
|
||||
const dateStr = formatDate(day);
|
||||
const dayAppts = appointments.filter((a) => a.startTime.startsWith(dateStr));
|
||||
if (viewMode !== "groomer" || hiddenGroomers.size === 0) return dayAppts;
|
||||
return dayAppts.filter((a) => !hiddenGroomers.has(a.staffId));
|
||||
});
|
||||
|
||||
function openNewForm(date?: Date) {
|
||||
setForm({ ...EMPTY_FORM, date: formatDate(date ?? new Date()) });
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
async function submitBooking(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.clientId || !form.petId || !form.serviceId) {
|
||||
setFormError("Client, pet, and service are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const service = services.find((s) => s.id === form.serviceId);
|
||||
if (!service) return;
|
||||
|
||||
const startISO = new Date(`${form.date}T${form.startTime}`).toISOString();
|
||||
const endDate = new Date(`${form.date}T${form.startTime}`);
|
||||
endDate.setMinutes(endDate.getMinutes() + service.durationMinutes);
|
||||
const endISO = endDate.toISOString();
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
clientId: form.clientId,
|
||||
petId: form.petId,
|
||||
serviceId: form.serviceId,
|
||||
staffId: form.staffId || undefined,
|
||||
batherStaffId: form.batherStaffId || undefined,
|
||||
startTime: startISO,
|
||||
endTime: endISO,
|
||||
notes: form.notes || undefined,
|
||||
};
|
||||
|
||||
if (form.recurring) {
|
||||
payload.recurrence = {
|
||||
frequencyWeeks: parseInt(form.recurrenceFrequencyWeeks),
|
||||
count: parseInt(form.recurrenceCount),
|
||||
};
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const res = await fetch("/api/appointments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setShowForm(false);
|
||||
await loadAppointments();
|
||||
} catch (e: unknown) {
|
||||
setFormError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus(appt: Appointment, status: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/appointments/${appt.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setSelectedAppt(null);
|
||||
await loadAppointments();
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : "Failed to update");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAppt(id: string, cascade: CascadeMode) {
|
||||
const url =
|
||||
cascade !== "this_only"
|
||||
? `/api/appointments/${id}?cascade=${cascade}`
|
||||
: `/api/appointments/${id}`;
|
||||
try {
|
||||
const res = await fetch(url, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : "Failed to delete appointment");
|
||||
}
|
||||
setSelectedAppt(null);
|
||||
await loadAppointments();
|
||||
}
|
||||
|
||||
if (loading) return <p style={{ padding: "1rem" }}>Loading…</p>;
|
||||
if (error) return <p style={{ padding: "1rem", color: "red" }}>Error: {error}</p>;
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||
{/* ── Header ── */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "1rem", flexWrap: "wrap" }}>
|
||||
<h1 style={{ margin: 0 }}>Appointments</h1>
|
||||
<button onClick={() => setWeekStart((w) => addDays(w, -7))} style={btnStyle}>
|
||||
← Prev
|
||||
</button>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
{fmtDateShort(weekStart)} – {fmtDateShort(weekEnd)}
|
||||
</span>
|
||||
<button onClick={() => setWeekStart((w) => addDays(w, 7))} style={btnStyle}>
|
||||
Next →
|
||||
</button>
|
||||
<button onClick={() => setWeekStart(startOfWeek(new Date()))} style={btnStyle}>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openNewForm()}
|
||||
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", marginLeft: "auto", borderColor: "var(--color-primary)" }}
|
||||
>
|
||||
+ New Appointment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Payment Stats Summary */}
|
||||
{paymentStats && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
|
||||
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</div>
|
||||
</div>
|
||||
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>${(paymentStats.outstanding / 100).toFixed(2)}</div>
|
||||
</div>
|
||||
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── View Mode + Groomer Filters ── */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
||||
{(["status", "groomer"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
style={{
|
||||
...btnStyle,
|
||||
backgroundColor: viewMode === mode ? "#1e293b" : "#f9fafb",
|
||||
color: viewMode === mode ? "#fff" : "#374151",
|
||||
borderColor: viewMode === mode ? "#1e293b" : "#d1d5db",
|
||||
}}
|
||||
>
|
||||
{mode === "status" ? "Status" : "Groomer"}
|
||||
</button>
|
||||
))}
|
||||
{viewMode === "groomer" && (
|
||||
<>
|
||||
<span style={{ fontSize: 13, color: "#6b7280", marginLeft: "0.5rem" }}>Show:</span>
|
||||
{activeGroomers.map((s) => {
|
||||
const color = groomerColorMap.get(s.id) ?? UNASSIGNED_COLOR;
|
||||
const visible = !hiddenGroomers.has(s.id);
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => toggleGroomer(s.id)}
|
||||
title={visible ? `Hide ${s.name}` : `Show ${s.name}`}
|
||||
style={{
|
||||
...btnStyle,
|
||||
backgroundColor: visible ? color : "#f1f5f9",
|
||||
color: visible ? "#fff" : "#94a3b8",
|
||||
borderColor: visible ? color : "#e2e8f0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.3rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: "50%", background: visible ? "#fff" : color, display: "inline-block" }} />
|
||||
{s.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Unassigned toggle */}
|
||||
{(() => {
|
||||
const visible = !hiddenGroomers.has(null);
|
||||
return (
|
||||
<button
|
||||
onClick={() => toggleGroomer(null)}
|
||||
style={{
|
||||
...btnStyle,
|
||||
backgroundColor: visible ? UNASSIGNED_COLOR : "#f1f5f9",
|
||||
color: visible ? "#fff" : "#94a3b8",
|
||||
borderColor: visible ? UNASSIGNED_COLOR : "#e2e8f0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.3rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: "50%", background: visible ? "#fff" : UNASSIGNED_COLOR, display: "inline-block" }} />
|
||||
Unassigned
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Weekly Calendar ── */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: "0.5rem" }}>
|
||||
{days.map((day, i) => {
|
||||
const isToday = formatDate(day) === formatDate(new Date());
|
||||
return (
|
||||
<div key={i} style={{ border: "1px solid #e5e7eb", borderRadius: 8, overflow: "hidden", minHeight: 180, background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.4rem 0.6rem",
|
||||
background: isToday ? "linear-gradient(135deg, var(--color-primary), var(--color-primary-dark))" : "#f8fafc",
|
||||
color: isToday ? "#fff" : "#374151",
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>{fmtDateShort(day)}</span>
|
||||
<button
|
||||
onClick={() => openNewForm(day)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: isToday ? "#fff" : "#6b7280",
|
||||
cursor: "pointer",
|
||||
fontSize: 16,
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
title="Add appointment"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: "0.3rem" }}>
|
||||
{(apptsByDay[i] ?? []).map((a) => {
|
||||
const svc = services.find((s) => s.id === a.serviceId);
|
||||
const cli = clients.find((c) => c.id === a.clientId);
|
||||
const groomer = staff.find((s) => s.id === a.staffId);
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
onClick={() => setSelectedAppt(a)}
|
||||
style={{
|
||||
background: apptColor(a),
|
||||
color: "#fff",
|
||||
borderRadius: 4,
|
||||
padding: "0.2rem 0.35rem",
|
||||
marginBottom: "0.2rem",
|
||||
fontSize: 11,
|
||||
cursor: "pointer",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{fmtTime(a.startTime)}</div>
|
||||
<div>{cli?.name ?? "—"}</div>
|
||||
<div style={{ opacity: 0.9 }}>{svc?.name ?? "—"}</div>
|
||||
{viewMode === "groomer" && (
|
||||
<div style={{ opacity: 0.85, fontSize: 10 }}>
|
||||
{groomer?.name ?? "Unassigned"}
|
||||
</div>
|
||||
)}
|
||||
{a.seriesId && (
|
||||
<div style={{ opacity: 0.85, fontSize: 10 }}>↻ recurring</div>
|
||||
)}
|
||||
{a.confirmationStatus === "confirmed" && (
|
||||
<div style={{ opacity: 0.95, fontSize: 10 }}>✓ confirmed</div>
|
||||
)}
|
||||
{a.confirmationStatus === "cancelled" && (
|
||||
<div style={{ opacity: 0.95, fontSize: 10, textDecoration: "line-through" }}>✗ cust. cancelled</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Booking Form Modal ── */}
|
||||
{showForm && (
|
||||
<Modal onClose={() => setShowForm(false)}>
|
||||
<h2 style={{ marginTop: 0 }}>New Appointment</h2>
|
||||
<form onSubmit={submitBooking}>
|
||||
<Field label="Client">
|
||||
<select
|
||||
value={form.clientId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, clientId: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— select client —</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Pet">
|
||||
<select
|
||||
value={form.petId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, petId: e.target.value }))}
|
||||
required
|
||||
disabled={!form.clientId}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— select pet —</option>
|
||||
{pets.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Service">
|
||||
<select
|
||||
value={form.serviceId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, serviceId: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— select service —</option>
|
||||
{services.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.durationMinutes} min — ${(s.basePriceCents / 100).toFixed(2)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Groomer (optional)">
|
||||
<select
|
||||
value={form.staffId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, staffId: e.target.value }))}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— any / unassigned —</option>
|
||||
{staff.filter((s) => s.active).map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Bather / Assistant (optional)">
|
||||
<select
|
||||
value={form.batherStaffId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, batherStaffId: e.target.value }))}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— none —</option>
|
||||
{staff.filter((s) => s.active).map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Date">
|
||||
<input
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Start time">
|
||||
<input
|
||||
type="time"
|
||||
value={form.startTime}
|
||||
onChange={(e) => setForm((f) => ({ ...f, startTime: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Notes">
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
rows={3}
|
||||
style={{ ...inputStyle, resize: "vertical" }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Recurrence */}
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", cursor: "pointer", fontSize: 13, fontWeight: 600, color: "#374151" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.recurring}
|
||||
onChange={(e) => setForm((f) => ({ ...f, recurring: e.target.checked }))}
|
||||
/>
|
||||
Recurring appointment
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{form.recurring && (
|
||||
<div
|
||||
style={{
|
||||
background: "#f0f9ff",
|
||||
border: "1px solid #bae6fd",
|
||||
borderRadius: 6,
|
||||
padding: "0.75rem",
|
||||
marginBottom: "0.75rem",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<Field label="Repeat every">
|
||||
<select
|
||||
value={form.recurrenceFrequencyWeeks}
|
||||
onChange={(e) => setForm((f) => ({ ...f, recurrenceFrequencyWeeks: e.target.value }))}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="2">2 weeks</option>
|
||||
<option value="4">4 weeks</option>
|
||||
<option value="6">6 weeks</option>
|
||||
<option value="8">8 weeks</option>
|
||||
<option value="12">12 weeks</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Number of appointments">
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={52}
|
||||
value={form.recurrenceCount}
|
||||
onChange={(e) => setForm((f) => ({ ...f, recurrenceCount: e.target.value }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{formError}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
|
||||
>
|
||||
{saving
|
||||
? "Saving…"
|
||||
: form.recurring
|
||||
? `Book ${form.recurrenceCount} appointments`
|
||||
: "Book Appointment"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* ── Appointment Detail Modal ── */}
|
||||
{selectedAppt && (
|
||||
<Modal onClose={() => setSelectedAppt(null)}>
|
||||
<AppointmentDetail
|
||||
appt={selectedAppt}
|
||||
clients={clients}
|
||||
services={services}
|
||||
staff={staff}
|
||||
onUpdateStatus={updateStatus}
|
||||
onDelete={deleteAppt}
|
||||
onClose={() => setSelectedAppt(null)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function AppointmentDetail({
|
||||
appt,
|
||||
clients,
|
||||
services,
|
||||
staff,
|
||||
onUpdateStatus,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
appt: Appointment;
|
||||
clients: Client[];
|
||||
services: Service[];
|
||||
staff: Staff[];
|
||||
onUpdateStatus: (a: Appointment, status: string) => void;
|
||||
onDelete: (id: string, cascade: CascadeMode) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [showDeleteOptions, setShowDeleteOptions] = useState(false);
|
||||
const [deleteCascade, setDeleteCascade] = useState<CascadeMode>("this_only");
|
||||
|
||||
const client = clients.find((c) => c.id === appt.clientId);
|
||||
const service = services.find((s) => s.id === appt.serviceId);
|
||||
const groomer = staff.find((s) => s.id === appt.staffId);
|
||||
const bather = staff.find((s) => s.id === appt.batherStaffId);
|
||||
const transitions = STATUS_TRANSITIONS[appt.status] ?? [];
|
||||
|
||||
function handleDeleteClick() {
|
||||
if (appt.seriesId) {
|
||||
setShowDeleteOptions(true);
|
||||
} else {
|
||||
if (confirm("Delete this appointment?")) {
|
||||
onDelete(appt.id, "this_only");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 style={{ marginTop: 0, display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
Appointment Details
|
||||
{appt.seriesId && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
background: "#ede9fe",
|
||||
color: "#6d28d9",
|
||||
padding: "0.15rem 0.5rem",
|
||||
borderRadius: 99,
|
||||
}}
|
||||
>
|
||||
↻ Recurring series
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<table style={{ borderCollapse: "collapse", width: "100%", marginBottom: "1rem", fontSize: 14 }}>
|
||||
<tbody>
|
||||
{([
|
||||
["Client", client?.name ?? "—"],
|
||||
["Service", service?.name ?? "—"],
|
||||
["Groomer", groomer?.name ?? "Unassigned"],
|
||||
...(bather ? [["Bather/Asst.", bather.name] as [string, string]] : []),
|
||||
["Start", new Date(appt.startTime).toLocaleString()],
|
||||
["End", new Date(appt.endTime).toLocaleString()],
|
||||
["Status", appt.status.replace("_", " ")],
|
||||
["Confirmation", appt.confirmationStatus === "confirmed"
|
||||
? `✓ Confirmed${appt.confirmedAt ? ` (${new Date(appt.confirmedAt).toLocaleString()})` : ""}`
|
||||
: appt.confirmationStatus === "cancelled"
|
||||
? `✗ Customer cancelled${appt.cancelledAt ? ` (${new Date(appt.cancelledAt).toLocaleString()})` : ""}`
|
||||
: "Pending"],
|
||||
["Notes", appt.notes ?? "—"],
|
||||
...(appt.customerNotes ? [["Customer Notes", appt.customerNotes] as [string, string]] : []),
|
||||
...(appt.seriesId
|
||||
? [["Series slot", `#${(appt.seriesIndex ?? 0) + 1}`] as [string, string]]
|
||||
: []),
|
||||
] as [string, string][]).map(([label, value]) => (
|
||||
<tr key={label}>
|
||||
<td style={{ padding: "4px 12px 4px 0", fontWeight: 600, whiteSpace: "nowrap", verticalAlign: "top", color: "#6b7280" }}>
|
||||
{label}
|
||||
</td>
|
||||
<td style={{ padding: "4px 0" }}>{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{transitions.length > 0 && (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, marginRight: "0.5rem" }}>Move to:</span>
|
||||
{transitions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onUpdateStatus(appt, s)}
|
||||
style={{
|
||||
...btnStyle,
|
||||
backgroundColor: STATUS_COLORS[s],
|
||||
color: "#fff",
|
||||
borderColor: STATUS_COLORS[s],
|
||||
marginRight: "0.4rem",
|
||||
marginBottom: "0.3rem",
|
||||
}}
|
||||
>
|
||||
{s.replace("_", " ")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cascade delete picker (series appointments only) */}
|
||||
{showDeleteOptions && (
|
||||
<div
|
||||
style={{
|
||||
background: "#fef2f2",
|
||||
border: "1px solid #fca5a5",
|
||||
borderRadius: 6,
|
||||
padding: "0.75rem",
|
||||
marginBottom: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: "0 0 0.5rem", fontWeight: 600, fontSize: 13 }}>
|
||||
This is part of a recurring series. Which appointments should be cancelled?
|
||||
</p>
|
||||
{(
|
||||
[
|
||||
["this_only", "This appointment only"],
|
||||
["this_and_future", "This and all future appointments in the series"],
|
||||
["all", "All appointments in the series"],
|
||||
] as [CascadeMode, string][]
|
||||
).map(([value, label]) => (
|
||||
<label
|
||||
key={value}
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.4rem", marginBottom: "0.35rem", fontSize: 13, cursor: "pointer" }}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="deleteCascade"
|
||||
value={value}
|
||||
checked={deleteCascade === value}
|
||||
onChange={() => setDeleteCascade(value)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
<button
|
||||
onClick={() => onDelete(appt.id, deleteCascade)}
|
||||
style={{ ...btnStyle, backgroundColor: "#ef4444", color: "#fff", borderColor: "#ef4444" }}
|
||||
>
|
||||
Confirm cancellation
|
||||
</button>
|
||||
<button onClick={() => setShowDeleteOptions(false)} style={btnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showDeleteOptions && (
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{appt.status !== "completed" && appt.status !== "cancelled" && (
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
style={{ ...btnStyle, backgroundColor: "#ef4444", color: "#fff", borderColor: "#ef4444" }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement;
|
||||
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const firstFocusable = focusableElements?.[0];
|
||||
firstFocusable?.focus();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
if (!modalRef.current) return;
|
||||
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 100,
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
padding: "1.5rem",
|
||||
maxWidth: 500,
|
||||
width: "calc(100% - 2rem)",
|
||||
maxHeight: "90vh",
|
||||
overflowY: "auto",
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" }}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.4rem 0.85rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "0.45rem 0.6rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
@@ -0,0 +1,612 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { Service } from "@groombook/types";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BookingBody {
|
||||
serviceId: string;
|
||||
startTime: string;
|
||||
clientName: string;
|
||||
clientEmail: string;
|
||||
clientPhone: string;
|
||||
petName: string;
|
||||
petSpecies: string;
|
||||
petBreed: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface BookingResult {
|
||||
appointment: { id: string; startTime: string; endTime: string };
|
||||
client: { id: string; name: string; email: string | null };
|
||||
pet: { id: string; name: string };
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtPrice(cents: number): string {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function fmtDuration(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function fmtDateLong(isoDate: string): string {
|
||||
const d = new Date(isoDate + "T12:00:00Z");
|
||||
return d.toLocaleDateString([], { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StepIndicator({ step }: { step: number }) {
|
||||
const steps = ["Service", "Date & Time", "Your Info", "Confirm"];
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 0, marginBottom: "1.5rem" }}>
|
||||
{steps.map((label, i) => {
|
||||
const idx = i + 1;
|
||||
const active = idx === step;
|
||||
const done = idx < step;
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
padding: "0.5rem 0.25rem",
|
||||
fontSize: 12,
|
||||
fontWeight: active ? 700 : 400,
|
||||
color: active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#9ca3af",
|
||||
borderBottom: `3px solid ${active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#e5e7eb"}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: "50%",
|
||||
background: active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#e5e7eb",
|
||||
color: active || done ? "#fff" : "#6b7280",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
{done ? "✓" : idx}
|
||||
</span>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function BookPage() {
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
// Step 1 — service
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [servicesLoading, setServicesLoading] = useState(true);
|
||||
const [selectedService, setSelectedService] = useState<Service | null>(null);
|
||||
|
||||
// Step 2 — date & time
|
||||
const [date, setDate] = useState(todayIso());
|
||||
const [dateError, setDateError] = useState<string | null>(null);
|
||||
const [slots, setSlots] = useState<string[]>([]);
|
||||
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
|
||||
|
||||
// Step 3 — contact info
|
||||
const [form, setForm] = useState<BookingBody>({
|
||||
serviceId: "",
|
||||
startTime: "",
|
||||
clientName: "",
|
||||
clientEmail: "",
|
||||
clientPhone: "",
|
||||
petName: "",
|
||||
petSpecies: "",
|
||||
petBreed: "",
|
||||
notes: "",
|
||||
});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
// Pre-fill form from URL params (e.g., ?clientName=Jane&clientEmail=jane@example.com)
|
||||
const [searchParams] = useSearchParams();
|
||||
useEffect(() => {
|
||||
const clientName = searchParams.get("clientName");
|
||||
const clientEmail = searchParams.get("clientEmail");
|
||||
const clientPhone = searchParams.get("clientPhone");
|
||||
const petName = searchParams.get("petName");
|
||||
const petSpecies = searchParams.get("petSpecies");
|
||||
const petBreed = searchParams.get("petBreed");
|
||||
if (clientName || clientEmail || clientPhone || petName || petSpecies || petBreed) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
...(clientName && { clientName }),
|
||||
...(clientEmail && { clientEmail }),
|
||||
...(clientPhone && { clientPhone }),
|
||||
...(petName && { petName }),
|
||||
...(petSpecies && { petSpecies }),
|
||||
...(petBreed && { petBreed }),
|
||||
}));
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Step 4 — result
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<BookingResult | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
// Load services on mount
|
||||
useEffect(() => {
|
||||
fetch("/api/book/services")
|
||||
.then((r) => r.json() as Promise<Service[]>)
|
||||
.then(setServices)
|
||||
.catch(() => setServices([]))
|
||||
.finally(() => setServicesLoading(false));
|
||||
}, []);
|
||||
|
||||
// Load slots when service or date changes (step 2)
|
||||
useEffect(() => {
|
||||
if (!selectedService || !date) return;
|
||||
setSlotsLoading(true);
|
||||
setSelectedSlot(null);
|
||||
fetch(
|
||||
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
|
||||
)
|
||||
.then((r) => r.json() as Promise<string[]>)
|
||||
.then(setSlots)
|
||||
.catch(() => setSlots([]))
|
||||
.finally(() => setSlotsLoading(false));
|
||||
}, [selectedService, date]);
|
||||
|
||||
function goToStep2(svc: Service) {
|
||||
setSelectedService(svc);
|
||||
setForm((f) => ({ ...f, serviceId: svc.id }));
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
function goToStep3() {
|
||||
if (!selectedSlot) return;
|
||||
setForm((f) => ({ ...f, startTime: selectedSlot }));
|
||||
setStep(3);
|
||||
}
|
||||
|
||||
function goToStep4() {
|
||||
if (!form.clientName.trim() || !form.clientEmail.trim() || !form.petName.trim() || !form.petSpecies.trim()) {
|
||||
setFormError("Please fill in all required fields.");
|
||||
return;
|
||||
}
|
||||
setFormError(null);
|
||||
setStep(4);
|
||||
}
|
||||
|
||||
async function submitBooking() {
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const res = await fetch("/api/book/appointments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
serviceId: form.serviceId,
|
||||
startTime: form.startTime,
|
||||
clientName: form.clientName,
|
||||
clientEmail: form.clientEmail,
|
||||
clientPhone: form.clientPhone || undefined,
|
||||
petName: form.petName,
|
||||
petSpecies: form.petSpecies,
|
||||
petBreed: form.petBreed || undefined,
|
||||
notes: form.notes || undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json()) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as BookingResult;
|
||||
setResult(data);
|
||||
setStep(5);
|
||||
} catch (e: unknown) {
|
||||
setSubmitError(e instanceof Error ? e.message : "Something went wrong. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Styles ──
|
||||
const card: React.CSSProperties = {
|
||||
background: "#fff",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
padding: "1rem",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const selectedCard: React.CSSProperties = {
|
||||
...card,
|
||||
border: "2px solid var(--color-primary)",
|
||||
background: "#f0faf5",
|
||||
};
|
||||
|
||||
const input: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "0.5rem 0.75rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const label: React.CSSProperties = {
|
||||
display: "block",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#374151",
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
const btn: React.CSSProperties = {
|
||||
padding: "0.6rem 1.25rem",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
...btn,
|
||||
background: "var(--color-primary)",
|
||||
color: "#fff",
|
||||
};
|
||||
|
||||
const secondaryBtn: React.CSSProperties = {
|
||||
...btn,
|
||||
background: "#f3f4f6",
|
||||
color: "#374151",
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 640, margin: "0 auto", padding: "1rem" }}>
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1f2937", margin: 0 }}>
|
||||
Book an Appointment
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: "#6b7280", marginTop: 4 }}>
|
||||
Schedule a grooming appointment for your pet in minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{step < 5 && <StepIndicator step={step} />}
|
||||
|
||||
{/* ── Step 1: Select Service ── */}
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: "0.75rem" }}>
|
||||
Choose a service
|
||||
</h2>
|
||||
{servicesLoading && <p style={{ color: "#6b7280" }}>Loading services…</p>}
|
||||
{!servicesLoading && services.length === 0 && (
|
||||
<p style={{ color: "#ef4444" }}>No services available. Please contact us to book.</p>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
{services.map((svc) => (
|
||||
<div
|
||||
key={svc.id}
|
||||
style={selectedService?.id === svc.id ? selectedCard : card}
|
||||
onClick={() => goToStep2(svc)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && goToStep2(svc)}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 15, color: "#1f2937" }}>{svc.name}</div>
|
||||
{svc.description && (
|
||||
<div style={{ fontSize: 13, color: "#6b7280", marginTop: 2 }}>{svc.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ textAlign: "right", flexShrink: 0, marginLeft: "1rem" }}>
|
||||
<div style={{ fontWeight: 700, color: "var(--color-primary)", fontSize: 15 }}>
|
||||
{fmtPrice(svc.basePriceCents)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#9ca3af" }}>{fmtDuration(svc.durationMinutes)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Date & Time ── */}
|
||||
{step === 2 && selectedService && (
|
||||
<div>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>Choose a date and time</h2>
|
||||
<p style={{ fontSize: 13, color: "#6b7280", marginBottom: "1rem" }}>
|
||||
{selectedService.name} — {fmtDuration(selectedService.durationMinutes)} — {fmtPrice(selectedService.basePriceCents)}
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={label}>Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
min={todayIso()}
|
||||
style={{ ...input, width: "auto" }}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// HTML5 date input enforces yyyy-MM-dd; empty value means invalid format
|
||||
if (!val) {
|
||||
setDateError("Please enter a valid date (YYYY-MM-DD).");
|
||||
setDate("");
|
||||
} else {
|
||||
setDateError(null);
|
||||
setDate(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{dateError && (
|
||||
<p style={{ color: "#dc2626", fontSize: 12, marginTop: 4 }}>{dateError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "1.25rem" }}>
|
||||
<label style={label}>Available times on {fmtDateLong(date)}</label>
|
||||
{slotsLoading && <p style={{ color: "#6b7280", fontSize: 13 }}>Checking availability…</p>}
|
||||
{!slotsLoading && slots.length === 0 && (
|
||||
<p style={{ color: "#6b7280", fontSize: 13 }}>
|
||||
No available slots on this date. Please try another day.
|
||||
</p>
|
||||
)}
|
||||
{!slotsLoading && slots.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
{slots.map((slot) => (
|
||||
<button
|
||||
key={slot}
|
||||
onClick={() => setSelectedSlot(slot)}
|
||||
style={{
|
||||
padding: "0.4rem 0.85rem",
|
||||
borderRadius: 6,
|
||||
border: `2px solid ${selectedSlot === slot ? "var(--color-primary)" : "#d1d5db"}`,
|
||||
background: selectedSlot === slot ? "var(--color-primary)" : "#fff",
|
||||
color: selectedSlot === slot ? "#fff" : "#374151",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{fmtTime(slot)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<button style={secondaryBtn} onClick={() => setStep(1)}>Back</button>
|
||||
<button
|
||||
style={{ ...primaryBtn, opacity: selectedSlot ? 1 : 0.5 }}
|
||||
disabled={!selectedSlot}
|
||||
onClick={goToStep3}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Contact Info ── */}
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: "1rem" }}>Your information</h2>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<fieldset style={{ border: "1px solid #e5e7eb", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<legend style={{ fontSize: 13, fontWeight: 600, color: "#374151", padding: "0 0.25rem" }}>
|
||||
Contact details
|
||||
</legend>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div>
|
||||
<label style={label}>Full name *</label>
|
||||
<input
|
||||
style={input}
|
||||
value={form.clientName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, clientName: e.target.value }))}
|
||||
placeholder="Jane Smith"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
style={input}
|
||||
value={form.clientEmail}
|
||||
onChange={(e) => setForm((f) => ({ ...f, clientEmail: e.target.value }))}
|
||||
placeholder="jane@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
style={input}
|
||||
value={form.clientPhone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, clientPhone: e.target.value }))}
|
||||
placeholder="(555) 000-1234"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style={{ border: "1px solid #e5e7eb", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<legend style={{ fontSize: 13, fontWeight: 600, color: "#374151", padding: "0 0.25rem" }}>
|
||||
Pet details
|
||||
</legend>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div>
|
||||
<label style={label}>Pet name *</label>
|
||||
<input
|
||||
style={input}
|
||||
value={form.petName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, petName: e.target.value }))}
|
||||
placeholder="Buddy"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>Species *</label>
|
||||
<select
|
||||
style={input}
|
||||
value={form.petSpecies}
|
||||
onChange={(e) => setForm((f) => ({ ...f, petSpecies: e.target.value }))}
|
||||
>
|
||||
<option value="">Select species…</option>
|
||||
<option value="dog">Dog</option>
|
||||
<option value="cat">Cat</option>
|
||||
<option value="rabbit">Rabbit</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>Breed</label>
|
||||
<input
|
||||
style={input}
|
||||
value={form.petBreed}
|
||||
onChange={(e) => setForm((f) => ({ ...f, petBreed: e.target.value }))}
|
||||
placeholder="Golden Retriever"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>Notes for groomer</label>
|
||||
<textarea
|
||||
style={{ ...input, minHeight: 64, resize: "vertical", fontFamily: "inherit" }}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
placeholder="Any special requests or things we should know…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p style={{ color: "#ef4444", fontSize: 13, marginTop: "0.75rem" }}>{formError}</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: "0.75rem", marginTop: "1.25rem" }}>
|
||||
<button style={secondaryBtn} onClick={() => setStep(2)}>Back</button>
|
||||
<button style={primaryBtn} onClick={goToStep4}>Review booking</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Confirm ── */}
|
||||
{step === 4 && selectedService && selectedSlot && (
|
||||
<div>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: "1rem" }}>Confirm your booking</h2>
|
||||
|
||||
<div style={{ ...card, cursor: "default", marginBottom: "1.25rem" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem", fontSize: 14 }}>
|
||||
<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>
|
||||
<div>
|
||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
|
||||
<div style={{ fontWeight: 600 }}>{fmtDateLong(date)}</div>
|
||||
<div style={{ color: "#6b7280" }}>{fmtTime(selectedSlot)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Client</div>
|
||||
<div style={{ fontWeight: 600 }}>{form.clientName}</div>
|
||||
<div style={{ color: "#6b7280" }}>{form.clientEmail}</div>
|
||||
{form.clientPhone && <div style={{ color: "#6b7280" }}>{form.clientPhone}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
{form.notes && (
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Notes</div>
|
||||
<div style={{ color: "#374151" }}>{form.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<p style={{ color: "#ef4444", fontSize: 13, marginBottom: "0.75rem" }}>{submitError}</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<button style={secondaryBtn} onClick={() => setStep(3)} disabled={submitting}>Back</button>
|
||||
<button
|
||||
style={{ ...primaryBtn, opacity: submitting ? 0.7 : 1 }}
|
||||
onClick={submitBooking}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Booking…" : "Confirm booking"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 5: Success ── */}
|
||||
{step === 5 && result && (
|
||||
<div style={{ textAlign: "center", padding: "2rem 1rem" }}>
|
||||
<div style={{ fontSize: 48, marginBottom: "0.75rem" }}>🐾</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: "#1f2937", marginBottom: "0.5rem" }}>
|
||||
Booking confirmed!
|
||||
</h2>
|
||||
<p style={{ color: "#6b7280", fontSize: 14, marginBottom: "1.5rem" }}>
|
||||
We've booked {result.pet.name} in for{" "}
|
||||
{selectedService?.name} on {fmtDateLong(date)} at{" "}
|
||||
{fmtTime(result.appointment.startTime)}.
|
||||
</p>
|
||||
<div style={{ ...card, cursor: "default", textAlign: "left", marginBottom: "1.5rem" }}>
|
||||
<p style={{ margin: 0, fontSize: 14, color: "#374151" }}>
|
||||
A confirmation will be sent to <strong>{result.client.email}</strong>.
|
||||
If you need to reschedule or cancel, please contact us.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
style={primaryBtn}
|
||||
onClick={() => {
|
||||
setStep(1);
|
||||
setSelectedService(null);
|
||||
setSelectedSlot(null);
|
||||
setResult(null);
|
||||
setForm({
|
||||
serviceId: "", startTime: "", clientName: "", clientEmail: "",
|
||||
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Book another appointment
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export function BookingCancelledPage() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#fff7ed",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "2.5rem 3rem",
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||
textAlign: "center",
|
||||
maxWidth: 420,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}>✗</div>
|
||||
<h1 style={{ color: "#c2410c", fontSize: 24, margin: "0 0 0.5rem" }}>
|
||||
Appointment Cancelled
|
||||
</h1>
|
||||
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
|
||||
Your appointment has been cancelled. If this was a mistake or you'd
|
||||
like to rebook, please contact us.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "0.6rem 1.5rem",
|
||||
background: "#ea580c",
|
||||
color: "#fff",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Back to Portal
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export function BookingConfirmedPage() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#f0fdf4",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "2.5rem 3rem",
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||
textAlign: "center",
|
||||
maxWidth: 420,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}>✓</div>
|
||||
<h1 style={{ color: "#15803d", fontSize: 24, margin: "0 0 0.5rem" }}>
|
||||
Appointment Confirmed
|
||||
</h1>
|
||||
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
|
||||
Thank you! Your appointment is confirmed. We look forward to seeing you
|
||||
and your furry friend.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "0.6rem 1.5rem",
|
||||
background: "#16a34a",
|
||||
color: "#fff",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Back to Portal
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export function BookingErrorPage() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#fef2f2",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "2.5rem 3rem",
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||
textAlign: "center",
|
||||
maxWidth: 420,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}>⚠️</div>
|
||||
<h1 style={{ color: "#b91c1c", fontSize: 24, margin: "0 0 0.5rem" }}>
|
||||
Link Invalid or Expired
|
||||
</h1>
|
||||
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
|
||||
This confirmation link is invalid, has already been used, or your
|
||||
appointment has already passed. Please contact us if you need help.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "0.6rem 1.5rem",
|
||||
background: "#dc2626",
|
||||
color: "#fff",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Back to Portal
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
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";
|
||||
|
||||
export function ClientDetailPage() {
|
||||
const { clientId } = useParams<{ clientId: string }>();
|
||||
const [client, setClient] = useState<Client | null>(null);
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [visitLogs, setVisitLogs] = useState<Record<string, GroomingVisitLog[]>>({});
|
||||
const [logsLoading, setLogsLoading] = useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [photoRevisions, setPhotoRevisions] = useState<Record<string, number>>({});
|
||||
|
||||
const handlePhotoUploaded = useCallback((petId: string) => {
|
||||
setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientId) {
|
||||
setError("No client ID provided");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const id = clientId!;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [clientRes, petsRes] = await Promise.all([
|
||||
fetch(`/api/clients/${encodeURIComponent(id)}`),
|
||||
fetch(`/api/pets?clientId=${encodeURIComponent(id)}`),
|
||||
]);
|
||||
|
||||
if (!clientRes.ok) {
|
||||
const err = await clientRes.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(err.error ?? `Client fetch failed: ${clientRes.status}`);
|
||||
}
|
||||
if (!petsRes.ok) {
|
||||
throw new Error(`Pets fetch failed: ${petsRes.status}`);
|
||||
}
|
||||
|
||||
setClient(await clientRes.json() as Client);
|
||||
setPets(await petsRes.json() as Pet[]);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load client");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
}, [clientId]);
|
||||
|
||||
async function loadVisitLogs(petId: string) {
|
||||
setLogsLoading((prev) => ({ ...prev, [petId]: true }));
|
||||
const r = await fetch(`/api/grooming-logs?petId=${encodeURIComponent(petId)}`);
|
||||
if (r.ok) {
|
||||
const logs = await r.json() as GroomingVisitLog[];
|
||||
setVisitLogs((prev) => ({ ...prev, [petId]: logs }));
|
||||
}
|
||||
setLogsLoading((prev) => ({ ...prev, [petId]: false }));
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: "2rem", textAlign: "center", color: "#6b7280", fontFamily: "system-ui, sans-serif" }}>
|
||||
Loading client…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !client) {
|
||||
return (
|
||||
<div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<Link to="/admin/clients" style={{ color: "#4f8a6f", fontSize: 13 }}>← Back to clients</Link>
|
||||
</div>
|
||||
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "1rem", color: "#991b1b" }}>
|
||||
{error ?? "Client not found"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1.5rem", gap: "1rem" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.25rem" }}>
|
||||
<h1 style={{ margin: 0, fontSize: 22 }}>{client.name}</h1>
|
||||
{client.status === "disabled" && (
|
||||
<span style={{ fontSize: 12, background: "#fef2f2", color: "#dc2626", padding: "0.15rem 0.5rem", borderRadius: 4, fontWeight: 500 }}>
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{client.email && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.email}</div>}
|
||||
{client.phone && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.phone}</div>}
|
||||
{client.address && <div style={{ fontSize: 13, color: "#6b7280" }}>{client.address}</div>}
|
||||
{client.notes && (
|
||||
<div style={{ fontSize: 13, marginTop: "0.4rem", background: "#fef9c3", padding: "0.4rem 0.6rem", borderRadius: 4, maxWidth: 500 }}>
|
||||
{client.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to="/admin/clients"
|
||||
style={{
|
||||
padding: "0.4rem 0.85rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
background: "#fff",
|
||||
color: "#374151",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
← Back to list
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Pets */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>Pets</h2>
|
||||
</div>
|
||||
|
||||
{pets.length === 0 ? (
|
||||
<p style={{ color: "#6b7280", fontSize: 14 }}>No pets on file for this client.</p>
|
||||
) : (
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,930 @@
|
||||
import { useEffect, useState, useCallback, useRef, useId } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
||||
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
|
||||
|
||||
// ─── Forms ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ClientForm {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface PetForm {
|
||||
name: string;
|
||||
species: string;
|
||||
breed: string;
|
||||
weightStr: string;
|
||||
dob: string;
|
||||
healthAlerts: string;
|
||||
groomingNotes: string;
|
||||
cutStyle: string;
|
||||
shampooPreference: string;
|
||||
specialCareNotes: string;
|
||||
}
|
||||
|
||||
interface VisitLogForm {
|
||||
cutStyle: string;
|
||||
productsUsed: string;
|
||||
notes: string;
|
||||
groomedAt: string;
|
||||
}
|
||||
|
||||
const EMPTY_CLIENT: ClientForm = { name: "", email: "", phone: "", address: "", notes: "" };
|
||||
const EMPTY_PET: PetForm = {
|
||||
name: "", species: "Dog", breed: "", weightStr: "", dob: "",
|
||||
healthAlerts: "", groomingNotes: "", cutStyle: "", shampooPreference: "", specialCareNotes: "",
|
||||
};
|
||||
const EMPTY_VISIT_LOG: VisitLogForm = { cutStyle: "", productsUsed: "", notes: "", groomedAt: "" };
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function ClientsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [petsLoading, setPetsLoading] = useState(false);
|
||||
const clientRowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Client form
|
||||
const [showClientForm, setShowClientForm] = useState(false);
|
||||
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
||||
const [clientForm, setClientForm] = useState<ClientForm>(EMPTY_CLIENT);
|
||||
const [clientFormError, setClientFormError] = useState<string | null>(null);
|
||||
const [savingClient, setSavingClient] = useState(false);
|
||||
|
||||
// Pet form
|
||||
const [showPetForm, setShowPetForm] = useState(false);
|
||||
const [editingPet, setEditingPet] = useState<Pet | null>(null);
|
||||
const [petForm, setPetForm] = useState<PetForm>(EMPTY_PET);
|
||||
const [petFormError, setPetFormError] = useState<string | null>(null);
|
||||
const [savingPet, setSavingPet] = useState(false);
|
||||
const [deletingPetId, setDeletingPetId] = useState<string | null>(null);
|
||||
const [deletingClient, setDeletingClient] = useState(false);
|
||||
const [disablingClient, setDisablingClient] = useState(false);
|
||||
const [startingImpersonation, setStartingImpersonation] = useState(false);
|
||||
const [showDisabled, setShowDisabled] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState("");
|
||||
|
||||
// Photo refresh counters (incremented after upload to force PetPhotoDisplay re-fetch)
|
||||
const [photoRevisions, setPhotoRevisions] = useState<Record<string, number>>({});
|
||||
const handlePhotoUploaded = useCallback((petId: string) => {
|
||||
setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 }));
|
||||
}, []);
|
||||
|
||||
// Visit log
|
||||
const [logPetId, setLogPetId] = useState<string | null>(null);
|
||||
const [visitLogs, setVisitLogs] = useState<Record<string, GroomingVisitLog[]>>({});
|
||||
const [logsLoading, setLogsLoading] = useState<Record<string, boolean>>({});
|
||||
const [showLogForm, setShowLogForm] = useState(false);
|
||||
const [logForm, setLogForm] = useState<VisitLogForm>(EMPTY_VISIT_LOG);
|
||||
const [logFormError, setLogFormError] = useState<string | null>(null);
|
||||
const [savingLog, setSavingLog] = useState(false);
|
||||
|
||||
async function loadClients(includeDisabled = false) {
|
||||
const url = includeDisabled ? "/api/clients?includeDisabled=true" : "/api/clients";
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
setClients((await r.json()) as Client[]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadClients(showDisabled)
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [showDisabled]);
|
||||
|
||||
// Auto-select a client when navigated here via GlobalSearch (?highlight=<clientId>)
|
||||
useEffect(() => {
|
||||
const highlightId = searchParams.get("highlight");
|
||||
if (!highlightId || loading || clients.length === 0) return;
|
||||
const match = clients.find((c) => c.id === highlightId);
|
||||
if (!match) return;
|
||||
selectClient(match);
|
||||
const el = clientRowRefs.current.get(highlightId);
|
||||
if (el) el.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
// Remove the param so back/refresh does not re-trigger
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("highlight");
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}, [searchParams, clients, loading]); // selectClient is stable (defined in render scope)
|
||||
|
||||
async function loadPets(clientId: string) {
|
||||
setPetsLoading(true);
|
||||
const r = await fetch(`/api/pets?clientId=${encodeURIComponent(clientId)}`);
|
||||
setPets((await r.json()) as Pet[]);
|
||||
setPetsLoading(false);
|
||||
}
|
||||
|
||||
async function loadVisitLogs(petId: string) {
|
||||
setLogsLoading((prev) => ({ ...prev, [petId]: true }));
|
||||
const r = await fetch(`/api/grooming-logs?petId=${encodeURIComponent(petId)}`);
|
||||
if (r.ok) {
|
||||
setVisitLogs((prev) => ({ ...prev, [petId]: (r.json() as unknown as Promise<GroomingVisitLog[]>).then ? [] : [] }));
|
||||
const logs = (await r.json()) as GroomingVisitLog[];
|
||||
setVisitLogs((prev) => ({ ...prev, [petId]: logs }));
|
||||
}
|
||||
setLogsLoading((prev) => ({ ...prev, [petId]: false }));
|
||||
}
|
||||
|
||||
function selectClient(c: Client) {
|
||||
setSelectedClient(c);
|
||||
loadPets(c.id);
|
||||
}
|
||||
|
||||
// ── Client CRUD ──
|
||||
|
||||
function openNewClient() {
|
||||
setEditingClient(null);
|
||||
setClientForm(EMPTY_CLIENT);
|
||||
setClientFormError(null);
|
||||
setShowClientForm(true);
|
||||
}
|
||||
|
||||
function openEditClient(c: Client) {
|
||||
setEditingClient(c);
|
||||
setClientForm({ name: c.name, email: c.email ?? "", phone: c.phone ?? "", address: c.address ?? "", notes: c.notes ?? "" });
|
||||
setClientFormError(null);
|
||||
setShowClientForm(true);
|
||||
}
|
||||
|
||||
async function submitClient(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSavingClient(true);
|
||||
setClientFormError(null);
|
||||
try {
|
||||
const body = {
|
||||
name: clientForm.name,
|
||||
email: clientForm.email || undefined,
|
||||
phone: clientForm.phone || undefined,
|
||||
address: clientForm.address || undefined,
|
||||
notes: clientForm.notes || undefined,
|
||||
};
|
||||
const res = editingClient
|
||||
? await fetch(`/api/clients/${editingClient.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
||||
: await fetch("/api/clients", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const updated = (await res.json()) as Client;
|
||||
setShowClientForm(false);
|
||||
await loadClients(showDisabled);
|
||||
if (editingClient) setSelectedClient(updated);
|
||||
} catch (e: unknown) {
|
||||
setClientFormError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSavingClient(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet CRUD ──
|
||||
|
||||
function openNewPet() {
|
||||
setEditingPet(null);
|
||||
setPetForm(EMPTY_PET);
|
||||
setPetFormError(null);
|
||||
setShowPetForm(true);
|
||||
}
|
||||
|
||||
function openEditPet(p: Pet) {
|
||||
setEditingPet(p);
|
||||
setPetForm({
|
||||
name: p.name, species: p.species, breed: p.breed ?? "",
|
||||
weightStr: p.weightKg != null ? String(p.weightKg) : "",
|
||||
dob: p.dateOfBirth ? p.dateOfBirth.slice(0, 10) : "",
|
||||
healthAlerts: p.healthAlerts ?? "",
|
||||
groomingNotes: p.groomingNotes ?? "",
|
||||
cutStyle: p.cutStyle ?? "",
|
||||
shampooPreference: p.shampooPreference ?? "",
|
||||
specialCareNotes: p.specialCareNotes ?? "",
|
||||
});
|
||||
setPetFormError(null);
|
||||
setShowPetForm(true);
|
||||
}
|
||||
|
||||
async function deletePet(petId: string) {
|
||||
if (!selectedClient) return;
|
||||
if (!window.confirm("Delete this pet? This cannot be undone.")) return;
|
||||
setDeletingPetId(petId);
|
||||
try {
|
||||
const res = await fetch(`/api/pets/${petId}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
await loadPets(selectedClient.id);
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : "Failed to delete pet");
|
||||
} finally {
|
||||
setDeletingPetId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableClient(clientId: string) {
|
||||
if (!window.confirm("Disable this client? They will be hidden from the client list and booking flow.")) return;
|
||||
setDisablingClient(true);
|
||||
try {
|
||||
const res = await fetch(`/api/clients/${clientId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "disabled" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const updated = (await res.json()) as Client;
|
||||
setSelectedClient(updated);
|
||||
await loadClients(showDisabled);
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : "Failed to disable client");
|
||||
} finally {
|
||||
setDisablingClient(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableClient(clientId: string) {
|
||||
setDisablingClient(true);
|
||||
try {
|
||||
const res = await fetch(`/api/clients/${clientId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "active" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const updated = (await res.json()) as Client;
|
||||
setSelectedClient(updated);
|
||||
await loadClients(showDisabled);
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : "Failed to re-enable client");
|
||||
} finally {
|
||||
setDisablingClient(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClient(clientId: string) {
|
||||
setDeletingClient(true);
|
||||
try {
|
||||
const res = await fetch(`/api/clients/${clientId}?confirm=true`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setSelectedClient(null);
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteConfirmName("");
|
||||
setPets([]);
|
||||
await loadClients(showDisabled);
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : "Failed to delete client");
|
||||
} finally {
|
||||
setDeletingClient(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPet(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selectedClient) return;
|
||||
setSavingPet(true);
|
||||
setPetFormError(null);
|
||||
try {
|
||||
const body = {
|
||||
clientId: selectedClient.id,
|
||||
name: petForm.name,
|
||||
species: petForm.species,
|
||||
breed: petForm.breed || undefined,
|
||||
weightKg: petForm.weightStr ? parseFloat(petForm.weightStr) : undefined,
|
||||
dateOfBirth: petForm.dob ? new Date(petForm.dob).toISOString() : undefined,
|
||||
healthAlerts: petForm.healthAlerts || undefined,
|
||||
groomingNotes: petForm.groomingNotes || undefined,
|
||||
cutStyle: petForm.cutStyle || undefined,
|
||||
shampooPreference: petForm.shampooPreference || undefined,
|
||||
specialCareNotes: petForm.specialCareNotes || undefined,
|
||||
};
|
||||
const res = editingPet
|
||||
? await fetch(`/api/pets/${editingPet.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
||||
: await fetch("/api/pets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setShowPetForm(false);
|
||||
await loadPets(selectedClient.id);
|
||||
} catch (e: unknown) {
|
||||
setPetFormError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSavingPet(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Visit Log ──
|
||||
|
||||
function openLogForm(petId: string) {
|
||||
setLogPetId(petId);
|
||||
setLogForm({ ...EMPTY_VISIT_LOG, groomedAt: new Date().toISOString().slice(0, 16) });
|
||||
setLogFormError(null);
|
||||
setShowLogForm(true);
|
||||
// Load existing logs for this pet
|
||||
if (!visitLogs[petId]) {
|
||||
void loadVisitLogs(petId);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitVisitLog(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!logPetId) return;
|
||||
setSavingLog(true);
|
||||
setLogFormError(null);
|
||||
try {
|
||||
const body = {
|
||||
petId: logPetId,
|
||||
cutStyle: logForm.cutStyle || undefined,
|
||||
productsUsed: logForm.productsUsed || undefined,
|
||||
notes: logForm.notes || undefined,
|
||||
groomedAt: logForm.groomedAt ? new Date(logForm.groomedAt).toISOString() : undefined,
|
||||
};
|
||||
const res = await fetch("/api/grooming-logs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setShowLogForm(false);
|
||||
await loadVisitLogs(logPetId);
|
||||
} catch (e: unknown) {
|
||||
setLogFormError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSavingLog(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = search
|
||||
? clients.filter((c) =>
|
||||
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.email?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.phone?.includes(search)
|
||||
)
|
||||
: clients;
|
||||
|
||||
if (loading) return <p style={{ padding: "1rem" }}>Loading…</p>;
|
||||
if (error) return <p style={{ padding: "1rem", color: "red" }}>Error: {error}</p>;
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif", display: "flex", gap: "1.5rem" }}>
|
||||
{/* ── Client list ── */}
|
||||
<div style={{ width: 280, flexShrink: 0, borderRight: "1px solid #e2e8f0", paddingRight: "1rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: "0.75rem" }}>
|
||||
<h1 style={{ margin: 0, fontSize: 20 }}>Clients</h1>
|
||||
<button
|
||||
onClick={openNewClient}
|
||||
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto", padding: "0.3rem 0.7rem" }}
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ ...inputStyle, marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem", fontSize: 12, color: "#6b7280", marginBottom: "0.75rem", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDisabled}
|
||||
onChange={(e) => setShowDisabled(e.target.checked)}
|
||||
/>
|
||||
Show disabled clients
|
||||
</label>
|
||||
{filtered.length === 0 && <p style={{ color: "#6b7280", fontSize: 14 }}>No clients found.</p>}
|
||||
{filtered.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
ref={(el) => {
|
||||
if (el) clientRowRefs.current.set(c.id, el);
|
||||
else clientRowRefs.current.delete(c.id);
|
||||
}}
|
||||
onClick={() => selectClient(c)}
|
||||
style={{
|
||||
padding: "0.5rem 0.6rem", borderRadius: 6, cursor: "pointer", marginBottom: "0.2rem",
|
||||
background: selectedClient?.id === c.id ? "#eff6ff" : "transparent",
|
||||
border: selectedClient?.id === c.id ? "1px solid #bfdbfe" : "1px solid transparent",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, display: "flex", alignItems: "center", gap: "0.4rem" }}>
|
||||
{c.name}
|
||||
{c.status === "disabled" && (
|
||||
<span style={{ fontSize: 10, background: "#fef2f2", color: "#dc2626", padding: "0.1rem 0.4rem", borderRadius: 4, fontWeight: 500 }}>
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{c.email && <div style={{ fontSize: 12, color: "#6b7280" }}>{c.email}</div>}
|
||||
{c.phone && <div style={{ fontSize: 12, color: "#6b7280" }}>{c.phone}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Client detail ── */}
|
||||
{selectedClient ? (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1rem" }}>
|
||||
<div>
|
||||
<h2 style={{ margin: "0 0 0.2rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
{selectedClient.name}
|
||||
{selectedClient.status === "disabled" && (
|
||||
<span style={{ fontSize: 12, background: "#fef2f2", color: "#dc2626", padding: "0.15rem 0.5rem", borderRadius: 4, fontWeight: 500 }}>
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{selectedClient.email && <div style={{ fontSize: 14, color: "#6b7280" }}>{selectedClient.email}</div>}
|
||||
{selectedClient.phone && <div style={{ fontSize: 14, color: "#6b7280" }}>{selectedClient.phone}</div>}
|
||||
{selectedClient.address && <div style={{ fontSize: 13, color: "#6b7280" }}>{selectedClient.address}</div>}
|
||||
{selectedClient.notes && (
|
||||
<div style={{ fontSize: 13, marginTop: "0.4rem", background: "#fef9c3", padding: "0.4rem 0.6rem", borderRadius: 4, maxWidth: 500 }}>
|
||||
{selectedClient.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginLeft: "auto" }}>
|
||||
<button
|
||||
disabled={startingImpersonation}
|
||||
onClick={async () => {
|
||||
if (!selectedClient) return;
|
||||
setStartingImpersonation(true);
|
||||
try {
|
||||
const res = await fetch("/api/impersonation/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: selectedClient.id,
|
||||
reason: `Support view for ${selectedClient.name}`,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const session = await res.json() as { id: string };
|
||||
window.location.href = `/?sessionId=${encodeURIComponent(session.id)}`;
|
||||
} else {
|
||||
const err = await res.json() as { error?: string; sessionId?: string };
|
||||
if (res.status === 409 && err.sessionId) {
|
||||
// Already have an active session — navigate to it
|
||||
window.location.href = `/?sessionId=${encodeURIComponent(err.sessionId)}`;
|
||||
} else {
|
||||
alert(`Could not start impersonation: ${err.error ?? res.statusText}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setStartingImpersonation(false);
|
||||
}
|
||||
}}
|
||||
style={{ ...btnStyle, backgroundColor: "#fef3c7", color: "#92400e", borderColor: "#fde68a", display: "inline-flex", alignItems: "center", gap: "0.3rem", opacity: startingImpersonation ? 0.6 : 1, cursor: startingImpersonation ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
{startingImpersonation ? "Starting…" : "View as Customer"}
|
||||
</button>
|
||||
<button onClick={() => openEditClient(selectedClient)} style={btnStyle}>
|
||||
Edit client
|
||||
</button>
|
||||
{selectedClient.status === "active" ? (
|
||||
<button
|
||||
onClick={() => { void disableClient(selectedClient.id); }}
|
||||
disabled={disablingClient}
|
||||
style={{ ...btnStyle, color: "#d97706", borderColor: "#fde68a" }}
|
||||
>
|
||||
{disablingClient ? "Disabling…" : "Disable client"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { void enableClient(selectedClient.id); }}
|
||||
disabled={disablingClient}
|
||||
style={{ ...btnStyle, color: "#059669", borderColor: "#6ee7b7" }}
|
||||
>
|
||||
{disablingClient ? "Enabling…" : "Re-enable client"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowDeleteConfirm(true); setDeleteConfirmName(""); }}
|
||||
style={{ ...btnStyle, color: "#dc2626", borderColor: "#fca5a5" }}
|
||||
>
|
||||
Delete permanently
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||
<h3 style={{ margin: 0 }}>Pets</h3>
|
||||
<button onClick={openNewPet} style={{ ...btnStyle, backgroundColor: "#10b981", color: "#fff", borderColor: "#10b981" }}>
|
||||
+ Add pet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{petsLoading ? (
|
||||
<p style={{ fontSize: 14 }}>Loading pets…</p>
|
||||
) : pets.length === 0 ? (
|
||||
<p style={{ color: "#6b7280", fontSize: 14 }}>No pets on file for this client.</p>
|
||||
) : (
|
||||
<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 style={{ display: "flex", gap: "0.3rem" }}>
|
||||
<button onClick={() => openEditPet(p)} style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11 }}>Edit</button>
|
||||
<button
|
||||
onClick={() => openLogForm(p.id)}
|
||||
style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11, backgroundColor: "#eff6ff", borderColor: "#bfdbfe" }}
|
||||
>
|
||||
Log visit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { void deletePet(p.id); }}
|
||||
disabled={deletingPetId === p.id}
|
||||
style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11, color: "#dc2626", borderColor: "#fca5a5" }}
|
||||
>
|
||||
{deletingPetId === p.id ? "…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{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 (loaded on demand) */}
|
||||
{(() => {
|
||||
const logs = visitLogs[p.id];
|
||||
if (!logs || logs.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280", marginBottom: "0.25rem" }}>VISIT HISTORY</div>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#9ca3af", fontSize: 15 }}>
|
||||
Select a client to view details
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Client modal ── */}
|
||||
{showClientForm && (
|
||||
<Modal title={editingClient ? "Edit Client" : "New Client"} onClose={() => setShowClientForm(false)}>
|
||||
<form onSubmit={submitClient}>
|
||||
<Field label="Full name">
|
||||
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Email">
|
||||
<input type="email" value={clientForm.email} onChange={(e) => setClientForm((f) => ({ ...f, email: e.target.value }))} style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Phone">
|
||||
<input value={clientForm.phone} onChange={(e) => setClientForm((f) => ({ ...f, phone: e.target.value }))} style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Address">
|
||||
<input value={clientForm.address} onChange={(e) => setClientForm((f) => ({ ...f, address: e.target.value }))} style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Notes">
|
||||
<textarea value={clientForm.notes} onChange={(e) => setClientForm((f) => ({ ...f, notes: e.target.value }))} rows={3} style={{ ...inputStyle, resize: "vertical" }} />
|
||||
</Field>
|
||||
{clientFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{clientFormError}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button type="submit" disabled={savingClient} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
|
||||
{savingClient ? "Saving…" : editingClient ? "Save Changes" : "Create Client"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowClientForm(false)} style={btnStyle}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* ── Pet modal ── */}
|
||||
{showPetForm && (
|
||||
<Modal title={editingPet ? "Edit Pet" : "Add Pet"} onClose={() => setShowPetForm(false)}>
|
||||
<form onSubmit={submitPet}>
|
||||
<Field label="Pet name">
|
||||
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Species">
|
||||
<select value={petForm.species} onChange={(e) => setPetForm((f) => ({ ...f, species: e.target.value }))} style={inputStyle}>
|
||||
{["Dog", "Cat", "Rabbit", "Guinea Pig", "Other"].map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Breed (optional)">
|
||||
<input value={petForm.breed} onChange={(e) => setPetForm((f) => ({ ...f, breed: e.target.value }))} style={inputStyle} />
|
||||
</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>
|
||||
<Field label="Date of birth (optional)">
|
||||
<input type="date" value={petForm.dob} onChange={(e) => setPetForm((f) => ({ ...f, dob: e.target.value }))} style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Health alerts (allergies, conditions, medications)">
|
||||
<textarea
|
||||
value={petForm.healthAlerts}
|
||||
onChange={(e) => setPetForm((f) => ({ ...f, healthAlerts: e.target.value }))}
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: "vertical", borderColor: petForm.healthAlerts ? "#fca5a5" : undefined }}
|
||||
placeholder="e.g. Allergic to lavender, heart condition, on medication X"
|
||||
/>
|
||||
</Field>
|
||||
<div style={{ borderTop: "1px solid #e5e7eb", marginTop: "0.75rem", paddingTop: "0.75rem" }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#6b7280", marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Grooming Preferences
|
||||
</div>
|
||||
<Field label="Preferred cut style (optional)">
|
||||
<input
|
||||
value={petForm.cutStyle}
|
||||
onChange={(e) => setPetForm((f) => ({ ...f, cutStyle: e.target.value }))}
|
||||
style={inputStyle}
|
||||
placeholder="e.g. Puppy cut, Breed standard, Teddy bear"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Shampoo / product preference (optional)">
|
||||
<input
|
||||
value={petForm.shampooPreference}
|
||||
onChange={(e) => setPetForm((f) => ({ ...f, shampooPreference: e.target.value }))}
|
||||
style={inputStyle}
|
||||
placeholder="e.g. Hypoallergenic, Oatmeal, Whitening"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Special care instructions (optional)">
|
||||
<textarea
|
||||
value={petForm.specialCareNotes}
|
||||
onChange={(e) => setPetForm((f) => ({ ...f, specialCareNotes: e.target.value }))}
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: "vertical" }}
|
||||
placeholder="e.g. Needs a pee pad in pen, anxious around dryers, requires muzzle"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="General grooming notes (optional)">
|
||||
<textarea value={petForm.groomingNotes} onChange={(e) => setPetForm((f) => ({ ...f, groomingNotes: e.target.value }))} rows={2} style={{ ...inputStyle, resize: "vertical" }} />
|
||||
</Field>
|
||||
</div>
|
||||
{petFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{petFormError}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button type="submit" disabled={savingPet} style={{ ...btnStyle, backgroundColor: "#10b981", color: "#fff", borderColor: "#10b981" }}>
|
||||
{savingPet ? "Saving…" : editingPet ? "Save Changes" : "Add Pet"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowPetForm(false)} style={btnStyle}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* ── Visit log modal ── */}
|
||||
{showLogForm && logPetId && (
|
||||
<Modal title="Log Grooming Visit" onClose={() => setShowLogForm(false)}>
|
||||
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history…</p>}
|
||||
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#6b7280", marginBottom: "0.4rem", textTransform: "uppercase" }}>Past Visits</div>
|
||||
{visitLogs[logPetId].slice(0, 5).map((log) => (
|
||||
<div key={log.id} style={{ fontSize: 12, borderLeft: "2px solid #e2e8f0", paddingLeft: "0.5rem", marginBottom: "0.3rem", color: "#374151" }}>
|
||||
<strong>{new Date(log.groomedAt).toLocaleDateString()}</strong>
|
||||
{log.cutStyle && <span> · {log.cutStyle}</span>}
|
||||
{log.productsUsed && <span> · {log.productsUsed}</span>}
|
||||
{log.notes && <div style={{ color: "#6b7280" }}>{log.notes}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={submitVisitLog}>
|
||||
<Field label="Date & time">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={logForm.groomedAt}
|
||||
onChange={(e) => setLogForm((f) => ({ ...f, groomedAt: e.target.value }))}
|
||||
style={inputStyle}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Cut style (optional)">
|
||||
<input
|
||||
value={logForm.cutStyle}
|
||||
onChange={(e) => setLogForm((f) => ({ ...f, cutStyle: e.target.value }))}
|
||||
style={inputStyle}
|
||||
placeholder="e.g. Puppy cut, Kennel cut"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Products used (optional)">
|
||||
<input
|
||||
value={logForm.productsUsed}
|
||||
onChange={(e) => setLogForm((f) => ({ ...f, productsUsed: e.target.value }))}
|
||||
style={inputStyle}
|
||||
placeholder="e.g. Oatmeal shampoo, leave-in conditioner"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Notes (optional)">
|
||||
<textarea
|
||||
value={logForm.notes}
|
||||
onChange={(e) => setLogForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
rows={3}
|
||||
style={{ ...inputStyle, resize: "vertical" }}
|
||||
placeholder="Anything notable about this visit"
|
||||
/>
|
||||
</Field>
|
||||
{logFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{logFormError}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button type="submit" disabled={savingLog} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
|
||||
{savingLog ? "Saving…" : "Save Visit Log"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowLogForm(false)} style={btnStyle}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* ── Delete confirmation modal ── */}
|
||||
{showDeleteConfirm && selectedClient && (
|
||||
<Modal title="Permanently Delete Client" titleStyle={{ color: "#dc2626" }} onClose={() => setShowDeleteConfirm(false)}>
|
||||
<p style={{ fontSize: 14, color: "#374151" }}>
|
||||
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
|
||||
</p>
|
||||
<p style={{ fontSize: 14, color: "#374151" }}>
|
||||
Consider disabling the client instead, which preserves their data for reporting.
|
||||
</p>
|
||||
<Field label={`Type "${selectedClient.name}" to confirm`}>
|
||||
<input
|
||||
value={deleteConfirmName}
|
||||
onChange={(e) => setDeleteConfirmName(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder={selectedClient.name}
|
||||
/>
|
||||
</Field>
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button
|
||||
onClick={() => { void deleteClient(selectedClient.id); }}
|
||||
disabled={deletingClient || deleteConfirmName !== selectedClient.name}
|
||||
style={{
|
||||
...btnStyle,
|
||||
backgroundColor: deleteConfirmName === selectedClient.name ? "#dc2626" : "#f3f4f6",
|
||||
color: deleteConfirmName === selectedClient.name ? "#fff" : "#9ca3af",
|
||||
borderColor: deleteConfirmName === selectedClient.name ? "#dc2626" : "#d1d5db",
|
||||
}}
|
||||
>
|
||||
{deletingClient ? "Deleting…" : "Delete permanently"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowDeleteConfirm(false)} style={btnStyle}>Cancel</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared UI ───────────────────────────────────────────────────────────────
|
||||
|
||||
function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) {
|
||||
const titleId = useId();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement;
|
||||
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const firstFocusable = focusableElements?.[0];
|
||||
firstFocusable?.focus();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
if (!modalRef.current) return;
|
||||
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}
|
||||
>
|
||||
<h2 id={titleId} style={{ marginTop: 0, ...titleStyle }}>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" }}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, boxSizing: "border-box",
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface StaffUser {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface ClientUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
petCount: number;
|
||||
}
|
||||
|
||||
export function DevLoginSelector() {
|
||||
const navigate = useNavigate();
|
||||
const [staff, setStaff] = useState<StaffUser[]>([]);
|
||||
const [clients, setClients] = useState<ClientUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/dev/users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setStaff(data.staff ?? []);
|
||||
setClients(data.clients ?? []);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function selectUser(type: "staff" | "client", id: string, name: string) {
|
||||
localStorage.setItem("dev-user", JSON.stringify({ type, id, name }));
|
||||
navigate(type === "staff" ? "/admin" : "/");
|
||||
}
|
||||
|
||||
function skipLogin() {
|
||||
localStorage.removeItem("dev-user");
|
||||
navigate("/admin");
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<p style={{ color: "#6b7280" }}>Loading users...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={cardStyle}>
|
||||
<div style={{ textAlign: "center", marginBottom: "1.5rem" }}>
|
||||
<h1 style={{ margin: 0, fontSize: 22, color: "#1a202c" }}>
|
||||
<span style={{ color: "#4f8a6f" }}>Groom</span>Book
|
||||
</h1>
|
||||
<p style={{ margin: "0.5rem 0 0", color: "#6b7280", fontSize: 14 }}>
|
||||
Dev Login Selector
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 style={sectionStyle}>Staff</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{staff.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => selectUser("staff", s.userId ?? s.id, s.name)}
|
||||
style={userButtonStyle}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280" }}>
|
||||
{s.role} · {s.email}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 style={{ ...sectionStyle, marginTop: "1.5rem" }}>Clients</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{clients.map((cl) => (
|
||||
<button
|
||||
key={cl.id}
|
||||
onClick={() => selectUser("client", cl.id, cl.name)}
|
||||
style={userButtonStyle}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{cl.name}</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280" }}>
|
||||
{cl.petCount} pet{cl.petCount !== 1 ? "s" : ""}
|
||||
{cl.email ? ` \u00b7 ${cl.email}` : ""}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
|
||||
<button onClick={skipLogin} style={skipButtonStyle}>
|
||||
Continue as default dev user
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getDevUser(): { type: string; id: string; name: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem("dev-user");
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDevUser() {
|
||||
localStorage.removeItem("dev-user");
|
||||
}
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#f0f2f5",
|
||||
padding: "1rem",
|
||||
};
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "2rem",
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.08)",
|
||||
};
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
margin: "0 0 0.5rem",
|
||||
};
|
||||
|
||||
const userButtonStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "0.75rem 1rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "border-color 0.15s, background 0.15s",
|
||||
};
|
||||
|
||||
const skipButtonStyle: React.CSSProperties = {
|
||||
padding: "0.5rem 1.25rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
color: "#6b7280",
|
||||
};
|
||||
@@ -0,0 +1,583 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Client, Pet, Service, Staff } from "@groombook/types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PetSlot {
|
||||
petId: string;
|
||||
serviceId: string;
|
||||
staffId: string;
|
||||
endTime: string; // HH:MM
|
||||
}
|
||||
|
||||
interface GroupAppointment {
|
||||
id: string;
|
||||
petId: string;
|
||||
petName?: string;
|
||||
serviceId: string;
|
||||
serviceName?: string;
|
||||
staffId: string | null;
|
||||
staffName?: string | null;
|
||||
status: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
interface AppointmentGroup {
|
||||
id: string;
|
||||
clientId: string;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
appointments: GroupAppointment[];
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
scheduled: "#3b82f6",
|
||||
confirmed: "#10b981",
|
||||
in_progress: "#f59e0b",
|
||||
completed: "#6b7280",
|
||||
cancelled: "#ef4444",
|
||||
no_show: "#9ca3af",
|
||||
};
|
||||
|
||||
// ─── New Group Booking Form ───────────────────────────────────────────────────
|
||||
|
||||
function NewGroupBookingForm({
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
staff,
|
||||
onCreated,
|
||||
onClose,
|
||||
}: {
|
||||
clients: Client[];
|
||||
pets: Pet[];
|
||||
services: Service[];
|
||||
staff: Staff[];
|
||||
onCreated: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [startTime, setStartTime] = useState("09:00");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [petSlots, setPetSlots] = useState<PetSlot[]>([
|
||||
{ petId: "", serviceId: "", staffId: "", endTime: "10:00" },
|
||||
{ petId: "", serviceId: "", staffId: "", endTime: "10:00" },
|
||||
]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const clientPets = pets.filter((p) => p.clientId === clientId);
|
||||
const activeServices = services.filter((s) => s.active);
|
||||
const activeStaff = staff.filter((s) => s.active);
|
||||
|
||||
function addPetSlot() {
|
||||
setPetSlots((prev) => [
|
||||
...prev,
|
||||
{ petId: "", serviceId: "", staffId: "", endTime: "10:00" },
|
||||
]);
|
||||
}
|
||||
|
||||
function removePetSlot(i: number) {
|
||||
setPetSlots((prev) => prev.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
function updateSlot(i: number, field: keyof PetSlot, value: string) {
|
||||
setPetSlots((prev) =>
|
||||
prev.map((slot, idx) =>
|
||||
idx === i ? { ...slot, [field]: value } : slot
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-set end time based on service duration when service changes
|
||||
function handleServiceChange(i: number, serviceId: string) {
|
||||
const svc = services.find((s) => s.id === serviceId);
|
||||
if (svc && startTime) {
|
||||
const [h, m] = startTime.split(":").map(Number);
|
||||
const totalMins = (h ?? 0) * 60 + (m ?? 0) + svc.durationMinutes;
|
||||
const endH = String(Math.floor(totalMins / 60) % 24).padStart(2, "0");
|
||||
const endM = String(totalMins % 60).padStart(2, "0");
|
||||
updateSlot(i, "serviceId", serviceId);
|
||||
updateSlot(i, "endTime", `${endH}:${endM}`);
|
||||
} else {
|
||||
updateSlot(i, "serviceId", serviceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!clientId) { setError("Please select a client"); return; }
|
||||
if (petSlots.length < 2) { setError("Add at least 2 pets"); return; }
|
||||
if (petSlots.some((s) => !s.petId || !s.serviceId)) {
|
||||
setError("Each pet slot needs a pet and service selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const payload = {
|
||||
clientId,
|
||||
startTime: `${date}T${startTime}:00.000Z`,
|
||||
notes: notes || undefined,
|
||||
pets: petSlots.map((slot) => ({
|
||||
petId: slot.petId,
|
||||
serviceId: slot.serviceId,
|
||||
staffId: slot.staffId || undefined,
|
||||
endTime: `${date}T${slot.endTime}:00.000Z`,
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/appointment-groups", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
onCreated();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to create group booking");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<h2 style={{ marginTop: 0 }}>New Group Booking</h2>
|
||||
<p style={{ fontSize: 13, color: "#6b7280", marginTop: 0 }}>
|
||||
Book multiple pets from the same client in a single visit. Each pet can have a different groomer.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<Field label="Client">
|
||||
<select
|
||||
value={clientId}
|
||||
onChange={(e) => { setClientId(e.target.value); setPetSlots([{ petId: "", serviceId: "", staffId: "", endTime: "10:00" }, { petId: "", serviceId: "", staffId: "", endTime: "10:00" }]); }}
|
||||
required
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— Select client —</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<Field label="Date" style={{ flex: 1 }}>
|
||||
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} required style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Start Time" style={{ flex: 1 }}>
|
||||
<input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} required style={inputStyle} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: "0.5rem", color: "#374151" }}>
|
||||
Pets ({petSlots.length})
|
||||
</div>
|
||||
{petSlots.map((slot, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
background: "#f8fafc",
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: 6,
|
||||
padding: "0.75rem",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>Pet {i + 1}</span>
|
||||
{petSlots.length > 2 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePetSlot(i)}
|
||||
style={{ ...btnStyle, color: "#dc2626", fontSize: 12, padding: "0.2rem 0.5rem" }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}>
|
||||
<Field label="Pet">
|
||||
<select
|
||||
value={slot.petId}
|
||||
onChange={(e) => updateSlot(i, "petId", e.target.value)}
|
||||
required
|
||||
style={inputStyle}
|
||||
disabled={!clientId}
|
||||
>
|
||||
<option value="">— Select pet —</option>
|
||||
{clientPets.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name} ({p.species})</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Service">
|
||||
<select
|
||||
value={slot.serviceId}
|
||||
onChange={(e) => handleServiceChange(i, e.target.value)}
|
||||
required
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— Select service —</option>
|
||||
{activeServices.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Groomer (optional)">
|
||||
<select
|
||||
value={slot.staffId}
|
||||
onChange={(e) => updateSlot(i, "staffId", e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— Unassigned —</option>
|
||||
{activeStaff.filter((s) => s.role === "groomer").map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="End Time">
|
||||
<input
|
||||
type="time"
|
||||
value={slot.endTime}
|
||||
onChange={(e) => updateSlot(i, "endTime", e.target.value)}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addPetSlot} style={btnStyle}>
|
||||
+ Add another pet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Field label="Notes (optional)">
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: "vertical" }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{error && <p style={{ color: "#dc2626", margin: "0.5rem 0 0", fontSize: 13 }}>{error}</p>}
|
||||
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
|
||||
>
|
||||
{saving ? "Booking…" : "Create Group Booking"}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} style={btnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Group Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function GroupCard({
|
||||
group,
|
||||
onCancel,
|
||||
}: {
|
||||
group: AppointmentGroup;
|
||||
onCancel: (id: string) => void;
|
||||
}) {
|
||||
const startTime = group.appointments[0]?.startTime;
|
||||
const allCancelled = group.appointments.every((a) => a.status === "cancelled");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: 8,
|
||||
marginBottom: "0.75rem",
|
||||
background: allCancelled ? "#f8fafc" : "#fff",
|
||||
opacity: allCancelled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0.75rem 1rem",
|
||||
borderBottom: "1px solid #e2e8f0",
|
||||
background: "#f8fafc",
|
||||
borderRadius: "8px 8px 0 0",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ fontSize: 14 }}>
|
||||
Group Visit — {startTime ? fmtDate(startTime) : "—"}
|
||||
{startTime && ` at ${fmtTime(startTime)}`}
|
||||
</strong>
|
||||
{group.notes && (
|
||||
<span style={{ marginLeft: "0.75rem", fontSize: 12, color: "#6b7280" }}>
|
||||
{group.notes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!allCancelled && (
|
||||
<button
|
||||
onClick={() => onCancel(group.id)}
|
||||
style={{ ...btnStyle, color: "#dc2626", borderColor: "#dc2626", fontSize: 12 }}
|
||||
>
|
||||
Cancel All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "#fafafa" }}>
|
||||
{["Pet", "Service", "Groomer", "End Time", "Status"].map((h) => (
|
||||
<th key={h} style={{ textAlign: "left", padding: "0.4rem 1rem", fontWeight: 600, color: "#6b7280", fontSize: 12 }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.appointments.map((appt) => (
|
||||
<tr key={appt.id}>
|
||||
<td style={tdStyle}>{appt.petName ?? appt.petId}</td>
|
||||
<td style={tdStyle}>{appt.serviceName ?? appt.serviceId}</td>
|
||||
<td style={tdStyle}>{appt.staffName ?? <span style={{ color: "#9ca3af" }}>Unassigned</span>}</td>
|
||||
<td style={tdStyle}>{fmtTime(appt.endTime)}</td>
|
||||
<td style={tdStyle}>
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
background: `${STATUS_COLORS[appt.status] ?? "#6b7280"}22`,
|
||||
color: STATUS_COLORS[appt.status] ?? "#374151",
|
||||
}}
|
||||
>
|
||||
{appt.status.replace("_", " ")}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function GroupBookingPage() {
|
||||
const [groups, setGroups] = useState<AppointmentGroup[]>([]);
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [staff, setStaff] = useState<Staff[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [clientFilter, setClientFilter] = useState("");
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const qs = clientFilter ? `?clientId=${clientFilter}` : "";
|
||||
const [groupRes, clientRes, petRes, svcRes, staffRes] = await Promise.all([
|
||||
fetch(`/api/appointment-groups${qs}`),
|
||||
fetch("/api/clients"),
|
||||
fetch("/api/pets"),
|
||||
fetch("/api/services"),
|
||||
fetch("/api/staff"),
|
||||
]);
|
||||
|
||||
if (!groupRes.ok || !clientRes.ok || !petRes.ok || !svcRes.ok || !staffRes.ok) {
|
||||
throw new Error("Failed to load data");
|
||||
}
|
||||
|
||||
const [groupData, clientData, petData, svcData, staffData] = await Promise.all([
|
||||
groupRes.json() as Promise<AppointmentGroup[]>,
|
||||
clientRes.json() as Promise<Client[]>,
|
||||
petRes.json() as Promise<Pet[]>,
|
||||
svcRes.json() as Promise<Service[]>,
|
||||
staffRes.json() as Promise<Staff[]>,
|
||||
]);
|
||||
|
||||
setGroups(groupData);
|
||||
setClients(clientData);
|
||||
setPets(petData);
|
||||
setServices(svcData);
|
||||
setStaff(staffData);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, [clientFilter]); // re-fetch when client filter changes
|
||||
|
||||
async function cancelGroup(groupId: string) {
|
||||
if (!confirm("Cancel all appointments in this group visit?")) return;
|
||||
const res = await fetch(`/api/appointment-groups/${groupId}`, { method: "DELETE" });
|
||||
if (res.ok) loadAll();
|
||||
}
|
||||
|
||||
if (loading && groups.length === 0) return <p style={{ padding: "1rem" }}>Loading…</p>;
|
||||
if (error) return <p style={{ padding: "1rem", color: "#dc2626" }}>Error: {error}</p>;
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1.25rem", flexWrap: "wrap" }}>
|
||||
<h1 style={{ margin: 0 }}>Group Bookings</h1>
|
||||
<select
|
||||
value={clientFilter}
|
||||
onChange={(e) => setClientFilter(e.target.value)}
|
||||
style={{ ...inputStyle, width: "auto", minWidth: 180 }}
|
||||
>
|
||||
<option value="">All Clients</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
style={{ ...btnStyle, marginLeft: "auto", backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
|
||||
>
|
||||
+ New Group Booking
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "3rem 1rem", color: "#6b7280" }}>
|
||||
<p style={{ fontSize: 16, marginBottom: "0.5rem" }}>No group bookings yet.</p>
|
||||
<p style={{ fontSize: 13 }}>
|
||||
Use group bookings when a client brings multiple pets in the same visit — each pet can have a different groomer working simultaneously.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={{
|
||||
...group,
|
||||
appointments: group.appointments.map((appt) => ({
|
||||
...appt,
|
||||
petName: pets.find((p) => p.id === appt.petId)?.name,
|
||||
serviceName: services.find((s) => s.id === appt.serviceId)?.name,
|
||||
staffName: staff.find((s) => s.id === appt.staffId)?.name,
|
||||
})),
|
||||
}}
|
||||
onCancel={cancelGroup}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<NewGroupBookingForm
|
||||
clients={clients}
|
||||
pets={pets}
|
||||
services={services}
|
||||
staff={staff}
|
||||
onCreated={() => { setShowCreate(false); loadAll(); }}
|
||||
onClose={() => setShowCreate(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared UI helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
||||
maxWidth: 640, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: "0.5rem", ...style }}>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.2rem", fontSize: 12, color: "#6b7280" }}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.4rem 0.85rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "0.45rem 0.6rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: "0.5rem 1rem",
|
||||
borderBottom: "1px solid #f3f4f6",
|
||||
color: "#374151",
|
||||
};
|
||||
@@ -0,0 +1,931 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface InvoiceWithClient extends Invoice {
|
||||
clientName?: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtMoney(cents: number) {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, { bg: string; color: string }> = {
|
||||
draft: { bg: "#f3f4f6", color: "#6b7280" },
|
||||
pending: { bg: "#fef3c7", color: "#92400e" },
|
||||
paid: { bg: "#d1fae5", color: "#065f46" },
|
||||
void: { bg: "#fee2e2", color: "#991b1b" },
|
||||
};
|
||||
|
||||
// ─── Invoice Status Badge ─────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const { bg, color } = STATUS_COLORS[status] ?? { bg: "#f3f4f6", color: "#374151" };
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
background: bg,
|
||||
color,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Create Invoice Form ──────────────────────────────────────────────────────
|
||||
|
||||
interface CreateFromApptProps {
|
||||
appointments: Appointment[];
|
||||
clients: Client[];
|
||||
services: Service[];
|
||||
loading: boolean;
|
||||
onOpen: () => void;
|
||||
onCreated: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function CreateFromAppointmentForm({
|
||||
appointments,
|
||||
clients,
|
||||
services,
|
||||
loading,
|
||||
onOpen,
|
||||
onCreated,
|
||||
onClose,
|
||||
}: CreateFromApptProps) {
|
||||
const [selectedApptId, setSelectedApptId] = useState("");
|
||||
useEffect(() => { onOpen(); }, [onOpen]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Only show completed appointments without an invoice already
|
||||
const completedAppts = appointments.filter((a) => a.status === "completed");
|
||||
|
||||
function getClientName(clientId: string) {
|
||||
return clients.find((c) => c.id === clientId)?.name ?? clientId;
|
||||
}
|
||||
|
||||
function getServiceName(serviceId: string) {
|
||||
return services.find((s) => s.id === serviceId)?.name ?? serviceId;
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selectedApptId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/invoices/from-appointment/${selectedApptId}`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
onCreated();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to create invoice");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading…</p></Modal>;
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<h2 style={{ marginTop: 0 }}>Create Invoice from Appointment</h2>
|
||||
<form onSubmit={submit}>
|
||||
<Field label="Select Appointment">
|
||||
<select
|
||||
value={selectedApptId}
|
||||
onChange={(e) => setSelectedApptId(e.target.value)}
|
||||
required
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— Choose a completed appointment —</option>
|
||||
{completedAppts.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{fmtDate(a.startTime)} · {getClientName(a.clientId)} · {getServiceName(a.serviceId)}
|
||||
{a.priceCents ? ` · ${fmtMoney(a.priceCents)}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{completedAppts.length === 0 && (
|
||||
<p style={{ color: "#6b7280", fontSize: 13 }}>
|
||||
No completed appointments available. Mark an appointment as completed first.
|
||||
</p>
|
||||
)}
|
||||
{error && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{error}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !selectedApptId}
|
||||
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
|
||||
>
|
||||
{saving ? "Creating…" : "Create Invoice"}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} style={btnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Invoice Detail Modal ─────────────────────────────────────────────────────
|
||||
|
||||
function InvoiceDetailModal({
|
||||
invoice,
|
||||
allStaff,
|
||||
allAppointments,
|
||||
loading,
|
||||
onOpen,
|
||||
onClose,
|
||||
onUpdated,
|
||||
}: {
|
||||
invoice: Invoice;
|
||||
allStaff: Staff[];
|
||||
allAppointments: Appointment[];
|
||||
loading: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onUpdated: () => void;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => { onOpen(); }, [onOpen]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
||||
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
||||
const [showRefundDialog, setShowRefundDialog] = useState(false);
|
||||
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
||||
const [partialAmount, setPartialAmount] = useState("");
|
||||
const [stripeDetails, setStripeDetails] = useState<{ cardLast4: string | null; paymentStatus: string | null; stripeRefundId: string | null } | null>(null);
|
||||
|
||||
// Fetch Stripe details when modal opens for paid invoices with a payment intent
|
||||
useEffect(() => {
|
||||
if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
|
||||
fetch(`/api/invoices/${invoice.id}/stripe-details`)
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => { if (data) setStripeDetails(data); })
|
||||
.catch(() => {});
|
||||
} else {
|
||||
setStripeDetails(null);
|
||||
}
|
||||
}, [invoice.id, invoice.status, invoice.stripePaymentIntentId]);
|
||||
|
||||
// Tip split state: array of {staffId, staffName, pct}
|
||||
const linkedAppt = invoice.appointmentId
|
||||
? allAppointments.find((a) => a.id === invoice.appointmentId)
|
||||
: undefined;
|
||||
|
||||
function buildDefaultSplits(): Array<{ staffId: string | null; staffName: string; pct: number }> {
|
||||
const groomer = linkedAppt?.staffId
|
||||
? allStaff.find((s) => s.id === linkedAppt.staffId)
|
||||
: undefined;
|
||||
const bather = linkedAppt?.batherStaffId
|
||||
? allStaff.find((s) => s.id === linkedAppt.batherStaffId)
|
||||
: undefined;
|
||||
if (!groomer) return [];
|
||||
if (bather) {
|
||||
return [
|
||||
{ staffId: groomer.id, staffName: groomer.name, pct: 70 },
|
||||
{ staffId: bather.id, staffName: bather.name, pct: 30 },
|
||||
];
|
||||
}
|
||||
return [{ staffId: groomer.id, staffName: groomer.name, pct: 100 }];
|
||||
}
|
||||
|
||||
const existingSplits = (invoice.tipSplits ?? []).map((s: InvoiceTipSplit) => ({
|
||||
staffId: s.staffId,
|
||||
staffName: s.staffName,
|
||||
pct: parseFloat(s.sharePct),
|
||||
}));
|
||||
|
||||
const [tipSplits, setTipSplits] = useState<Array<{ staffId: string | null; staffName: string; pct: number }>>(
|
||||
existingSplits.length > 0 ? existingSplits : buildDefaultSplits()
|
||||
);
|
||||
const [showSplits, setShowSplits] = useState(tipSplits.length > 0);
|
||||
|
||||
async function markPaid() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const tipCents = Math.round(parseFloat(tipStr) * 100) || 0;
|
||||
// Real-time validation: prevent submit if tip splits don't sum to 100%
|
||||
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
|
||||
if (Math.abs(totalPct - 100) >= 0.01) {
|
||||
setError("Tip split percentages must sum to 100%");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const patchBody: {
|
||||
status: string;
|
||||
paymentMethod: string;
|
||||
tipCents: number;
|
||||
tipSplits?: Array<{ staffId: string | null; staffName: string; sharePct: number }>;
|
||||
} = { status: "paid", paymentMethod, tipCents };
|
||||
|
||||
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||
patchBody.tipSplits = tipSplits.map((r) => ({
|
||||
staffId: r.staffId,
|
||||
staffName: r.staffName,
|
||||
sharePct: r.pct,
|
||||
}));
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patchBody),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
onUpdated();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to update");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function voidInvoice() {
|
||||
if (!confirm("Void this invoice? This cannot be undone.")) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "void" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
onUpdated();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to void");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function issueRefund() {
|
||||
const amountCents = refundType === "partial"
|
||||
? Math.round(parseFloat(partialAmount) * 100)
|
||||
: undefined;
|
||||
if (refundType === "partial" && (!amountCents || amountCents <= 0)) {
|
||||
setError("Enter a valid refund amount");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(amountCents ? { amountCents } : {}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setShowRefundDialog(false);
|
||||
onUpdated();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to issue refund");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading…</p></Modal>;
|
||||
|
||||
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
|
||||
const newTotal = invoice.subtotalCents + invoice.taxCents + tipCentsCalc;
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" }}>
|
||||
<h2 style={{ margin: 0 }}>Invoice</h2>
|
||||
<StatusBadge status={invoice.status} />
|
||||
</div>
|
||||
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14, marginBottom: "1rem" }}>
|
||||
<thead>
|
||||
<tr style={{ background: "#f8fafc" }}>
|
||||
{["Description", "Qty", "Unit Price", "Total"].map((h) => (
|
||||
<th key={h} style={{ textAlign: "left", padding: "0.4rem 0.5rem", borderBottom: "1px solid #e2e8f0" }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(invoice.lineItems ?? []).map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td style={tdStyle}>{item.description}</td>
|
||||
<td style={tdStyle}>{item.quantity}</td>
|
||||
<td style={tdStyle}>{fmtMoney(item.unitPriceCents)}</td>
|
||||
<td style={tdStyle}>{fmtMoney(item.totalCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ borderTop: "1px solid #e2e8f0", paddingTop: "0.75rem", fontSize: 14 }}>
|
||||
<SummaryRow label="Subtotal" value={fmtMoney(invoice.subtotalCents)} />
|
||||
<SummaryRow label="Tax" value={fmtMoney(invoice.taxCents)} />
|
||||
{invoice.status !== "paid" && invoice.status !== "void" ? (
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.25rem 0" }}>
|
||||
<span style={{ color: "#6b7280" }}>Tip</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={tipStr}
|
||||
onChange={(e) => setTipStr(e.target.value)}
|
||||
style={{ ...inputStyle, width: 80, textAlign: "right" }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<SummaryRow label="Tip" value={fmtMoney(invoice.tipCents)} />
|
||||
)}
|
||||
<SummaryRow
|
||||
label="Total"
|
||||
value={fmtMoney(invoice.status !== "paid" && invoice.status !== "void" ? newTotal : invoice.totalCents)}
|
||||
bold
|
||||
/>
|
||||
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
||||
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
||||
{stripeDetails && (
|
||||
<>
|
||||
{stripeDetails.cardLast4 && (
|
||||
<SummaryRow label="Card" value={`•••• ${stripeDetails.cardLast4}`} />
|
||||
)}
|
||||
{stripeDetails.paymentStatus && (
|
||||
<SummaryRow label="Stripe status" value={stripeDetails.paymentStatus} />
|
||||
)}
|
||||
{stripeDetails.stripeRefundId && (
|
||||
<SummaryRow label="Refund" value="Refunded" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Tip Distribution ── */}
|
||||
{invoice.status !== "void" && (
|
||||
<div style={{ marginTop: "0.75rem", borderTop: "1px solid #e2e8f0", paddingTop: "0.75rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>Tip Distribution</span>
|
||||
{invoice.status !== "paid" && (
|
||||
<button
|
||||
onClick={() => setShowSplits((v) => !v)}
|
||||
style={{ ...btnStyle, fontSize: 12 }}
|
||||
>
|
||||
{showSplits ? "Hide" : "Set up"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show existing splits on paid invoices */}
|
||||
{invoice.status === "paid" && (invoice.tipSplits ?? []).length > 0 && (
|
||||
<table style={{ width: "100%", fontSize: 13, borderCollapse: "collapse" }}>
|
||||
<tbody>
|
||||
{(invoice.tipSplits ?? []).map((s: InvoiceTipSplit) => (
|
||||
<tr key={s.id}>
|
||||
<td style={{ padding: "2px 0", color: "#374151" }}>{s.staffName}</td>
|
||||
<td style={{ padding: "2px 0", color: "#6b7280", textAlign: "right" }}>{parseFloat(s.sharePct).toFixed(0)}%</td>
|
||||
<td style={{ padding: "2px 0", textAlign: "right", fontWeight: 600 }}>{fmtMoney(s.shareCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{invoice.status === "paid" && (invoice.tipSplits ?? []).length === 0 && (
|
||||
<p style={{ fontSize: 12, color: "#9ca3af", margin: 0 }}>No split recorded.</p>
|
||||
)}
|
||||
|
||||
{/* Editable splits before payment */}
|
||||
{invoice.status !== "paid" && showSplits && (
|
||||
<div>
|
||||
{tipSplits.map((row, idx) => {
|
||||
const splitTipCents = Math.round((row.pct / 100) * (Math.round(parseFloat(tipStr) * 100) || 0));
|
||||
return (
|
||||
<div key={idx} style={{ display: "flex", alignItems: "center", gap: "0.4rem", marginBottom: "0.35rem", fontSize: 13 }}>
|
||||
<input
|
||||
value={row.staffName}
|
||||
onChange={(e) => setTipSplits((prev) => prev.map((r, i) => i === idx ? { ...r, staffName: e.target.value } : r))}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
placeholder="Name"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={row.pct}
|
||||
onChange={(e) => setTipSplits((prev) => prev.map((r, i) => i === idx ? { ...r, pct: Number(e.target.value) } : r))}
|
||||
style={{ ...inputStyle, width: 60, textAlign: "right" }}
|
||||
/>
|
||||
<span style={{ color: "#6b7280" }}>%</span>
|
||||
<span style={{ minWidth: 60, textAlign: "right", color: "#374151" }}>{fmtMoney(splitTipCents)}</span>
|
||||
<button
|
||||
onClick={() => setTipSplits((prev) => prev.filter((_, i) => i !== idx))}
|
||||
style={{ ...btnStyle, color: "#dc2626", borderColor: "#dc2626", padding: "0.2rem 0.4rem" }}
|
||||
>×</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginTop: "0.25rem" }}>
|
||||
<button
|
||||
onClick={() => setTipSplits((prev) => [...prev, { staffId: null, staffName: "", pct: 0 }])}
|
||||
style={{ ...btnStyle, fontSize: 12 }}
|
||||
>
|
||||
+ Add person
|
||||
</button>
|
||||
{(() => {
|
||||
const total = tipSplits.reduce((s, r) => s + r.pct, 0);
|
||||
const ok = Math.abs(total - 100) < 0.01;
|
||||
return <span style={{ fontSize: 12, color: ok ? "#10b981" : "#ef4444" }}>Total: {total.toFixed(0)}%{ok ? " ✓" : " (must be 100%)"}</span>;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.status !== "paid" && invoice.status !== "void" && (
|
||||
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
|
||||
<Field label="Payment Method">
|
||||
<select
|
||||
value={paymentMethod}
|
||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="card">Card</option>
|
||||
<option value="check">Check</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</Field>
|
||||
{error && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{error}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
|
||||
<button
|
||||
onClick={markPaid}
|
||||
disabled={saving}
|
||||
style={{ ...btnStyle, backgroundColor: "#10b981", color: "#fff", borderColor: "#10b981" }}
|
||||
>
|
||||
{saving ? "Saving…" : "Mark as Paid"}
|
||||
</button>
|
||||
<button onClick={voidInvoice} disabled={saving} style={{ ...btnStyle, color: "#dc2626", borderColor: "#dc2626" }}>
|
||||
Void
|
||||
</button>
|
||||
<button onClick={onClose} style={btnStyle}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(invoice.status === "paid" || invoice.status === "void") && (
|
||||
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
||||
{invoice.status === "paid" && invoice.stripePaymentIntentId && (
|
||||
<button
|
||||
onClick={() => setShowRefundDialog(true)}
|
||||
style={{ ...btnStyle, color: "#b45309", borderColor: "#b45309" }}
|
||||
>
|
||||
Refund
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refund Dialog */}
|
||||
{showRefundDialog && (
|
||||
<Modal onClose={() => setShowRefundDialog(false)}>
|
||||
<h2 style={{ marginTop: 0 }}>Issue Refund</h2>
|
||||
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
|
||||
Invoice total: <strong>{fmtMoney(invoice.totalCents)}</strong>
|
||||
</p>
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600, marginBottom: "0.5rem" }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="refundType"
|
||||
value="full"
|
||||
checked={refundType === "full"}
|
||||
onChange={() => setRefundType("full")}
|
||||
/>
|
||||
Full refund
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600 }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="refundType"
|
||||
value="partial"
|
||||
checked={refundType === "partial"}
|
||||
onChange={() => setRefundType("partial")}
|
||||
/>
|
||||
Partial refund
|
||||
</label>
|
||||
</div>
|
||||
{refundType === "partial" && (
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={partialAmount}
|
||||
onChange={(e) => setPartialAmount(e.target.value)}
|
||||
style={{ ...inputStyle, width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
|
||||
<button
|
||||
onClick={issueRefund}
|
||||
disabled={saving}
|
||||
style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
|
||||
>
|
||||
{saving ? "Processing…" : "Issue Refund"}
|
||||
</button>
|
||||
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
padding: "0.25rem 0",
|
||||
fontWeight: bold ? 700 : 400,
|
||||
fontSize: bold ? 15 : 14,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: bold ? "#111827" : "#6b7280" }}>{label}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function InvoicesPage() {
|
||||
const [invoiceList, setInvoiceList] = useState<InvoiceWithClient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [selectedInvoice, setSelectedInvoice] = useState<Invoice | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [createData, setCreateData] = useState<{ clients: Client[]; appointments: Appointment[]; services: Service[] } | null>(null);
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
|
||||
|
||||
const LIMIT = 50;
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/invoices/stats/summary")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => { if (data) setPaymentStats(data); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadInvoices(newOffset: number) {
|
||||
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
|
||||
if (statusFilter) params.set("status", statusFilter);
|
||||
const res = await fetch(`/api/invoices?${params}`);
|
||||
if (!res.ok) throw new Error("Failed to load invoices");
|
||||
const page = (await res.json()) as PaginatedResponse<Invoice>;
|
||||
setInvoiceList(page.data);
|
||||
setTotal(page.total);
|
||||
setOffset(newOffset);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadInvoices(0)
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [statusFilter]);
|
||||
|
||||
function loadCreateData() {
|
||||
if (createData) return Promise.resolve();
|
||||
setCreateLoading(true);
|
||||
return Promise.all([
|
||||
fetch("/api/clients"),
|
||||
fetch("/api/appointments"),
|
||||
fetch("/api/services?includeInactive=true"),
|
||||
])
|
||||
.then(([c, a, s]) => Promise.all([c.json(), a.json(), s.json()]))
|
||||
.then(([clients, appointments, services]) => {
|
||||
setCreateData({ clients, appointments, services });
|
||||
})
|
||||
.finally(() => setCreateLoading(false));
|
||||
}
|
||||
|
||||
function loadDetailData() {
|
||||
if (detailData) return Promise.resolve();
|
||||
setDetailLoading(true);
|
||||
return Promise.all([fetch("/api/staff"), fetch("/api/appointments")])
|
||||
.then(([s, a]) => Promise.all([s.json(), a.json()]))
|
||||
.then(([staff, appointments]) => {
|
||||
setDetailData({ staff, appointments });
|
||||
})
|
||||
.finally(() => setDetailLoading(false));
|
||||
}
|
||||
|
||||
async function openInvoiceDetail(inv: InvoiceWithClient) {
|
||||
const res = await fetch(`/api/invoices/${inv.id}`);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as Invoice;
|
||||
setSelectedInvoice(data);
|
||||
loadDetailData();
|
||||
}
|
||||
|
||||
if (loading) return <p style={{ padding: "1rem" }}>Loading…</p>;
|
||||
if (error) return <p style={{ padding: "1rem", color: "red" }}>Error: {error}</p>;
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
|
||||
<h1 style={{ margin: 0 }}>Invoices</h1>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
style={{ ...inputStyle, width: "auto" }}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="void">Void</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto" }}
|
||||
>
|
||||
+ Create Invoice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Payment Stats Summary */}
|
||||
{paymentStats && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
|
||||
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>{fmtMoney(paymentStats.revenueThisMonth)}</div>
|
||||
</div>
|
||||
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>{fmtMoney(paymentStats.outstanding)}</div>
|
||||
</div>
|
||||
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>{fmtMoney(paymentStats.refundsThisMonth)}</div>
|
||||
</div>
|
||||
{paymentStats.methodBreakdown.length > 0 && (
|
||||
<div style={{ background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#475569", fontWeight: 600, marginBottom: "0.25rem" }}>By method</div>
|
||||
<div style={{ fontSize: 13, color: "#64748b" }}>
|
||||
{paymentStats.methodBreakdown.map((b) => (
|
||||
<div key={b.method ?? "unknown"}>{b.method ?? "other"}: {b.total}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoiceList.length === 0 ? (
|
||||
<p style={{ color: "#6b7280" }}>
|
||||
No invoices yet. Create one from a completed appointment.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "#f8fafc" }}>
|
||||
{["Date", "Client", "Subtotal", "Tax", "Tip", "Total", "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>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoiceList.map((inv) => (
|
||||
<tr key={inv.id} style={{ opacity: inv.status === "void" ? 0.5 : 1 }}>
|
||||
<td style={tdStyle}>{fmtDate(inv.createdAt)}</td>
|
||||
<td style={tdStyle}>{inv.clientName ?? "—"}</td>
|
||||
<td style={tdStyle}>{fmtMoney(inv.subtotalCents)}</td>
|
||||
<td style={tdStyle}>{fmtMoney(inv.taxCents)}</td>
|
||||
<td style={tdStyle}>{fmtMoney(inv.tipCents)}</td>
|
||||
<td style={{ ...tdStyle, fontWeight: 600 }}>{fmtMoney(inv.totalCents)}</td>
|
||||
<td style={tdStyle}>
|
||||
<StatusBadge status={inv.status} />
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<button onClick={() => openInvoiceDetail(inv)} style={btnStyle}>
|
||||
View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "0.75rem" }}>
|
||||
<span style={{ fontSize: 13, color: "#6b7280" }}>
|
||||
{offset + 1}–{Math.min(offset + LIMIT, total)} of {total} invoices
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button
|
||||
onClick={() => { setLoading(true); loadInvoices(Math.max(0, offset - LIMIT)).finally(() => setLoading(false)); }}
|
||||
disabled={offset === 0 || loading}
|
||||
style={btnStyle}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setLoading(true); loadInvoices(offset + LIMIT).finally(() => setLoading(false)); }}
|
||||
disabled={offset + LIMIT >= total || loading}
|
||||
style={btnStyle}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<CreateFromAppointmentForm
|
||||
appointments={createData?.appointments ?? []}
|
||||
clients={createData?.clients ?? []}
|
||||
services={createData?.services ?? []}
|
||||
loading={createLoading}
|
||||
onOpen={() => loadCreateData()}
|
||||
onCreated={() => {
|
||||
setShowCreate(false);
|
||||
setCreateData(null);
|
||||
loadInvoices(0).catch(() => {});
|
||||
}}
|
||||
onClose={() => setShowCreate(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedInvoice && (
|
||||
<InvoiceDetailModal
|
||||
invoice={selectedInvoice}
|
||||
allStaff={detailData?.staff ?? []}
|
||||
allAppointments={detailData?.appointments ?? []}
|
||||
loading={detailLoading}
|
||||
onOpen={() => loadDetailData()}
|
||||
onClose={() => {
|
||||
setSelectedInvoice(null);
|
||||
setDetailData(null);
|
||||
}}
|
||||
onUpdated={() => {
|
||||
setSelectedInvoice(null);
|
||||
setDetailData(null);
|
||||
loadInvoices(offset).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared UI helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement;
|
||||
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const firstFocusable = focusableElements?.[0];
|
||||
firstFocusable?.focus();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
if (!modalRef.current) return;
|
||||
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={{
|
||||
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
||||
maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" }}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.4rem 0.85rem", border: "1px solid #d1d5db",
|
||||
borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db",
|
||||
borderRadius: 6, fontSize: 14, boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6",
|
||||
};
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Summary {
|
||||
from: string;
|
||||
to: string;
|
||||
revenue: { totalCents: number; paidInvoices: number };
|
||||
appointments: { total: number; completed: number; cancelled: number; noShow: number };
|
||||
clients: { total: number; new: number };
|
||||
}
|
||||
|
||||
interface RevenuePeriod {
|
||||
period: string;
|
||||
totalCents: number;
|
||||
invoiceCount: number;
|
||||
}
|
||||
|
||||
interface RevenueByGroomer {
|
||||
staffId: string;
|
||||
staffName: string;
|
||||
totalCents: number;
|
||||
invoiceCount: number;
|
||||
}
|
||||
|
||||
interface RevenueReport {
|
||||
byPeriod: RevenuePeriod[];
|
||||
byGroomer: RevenueByGroomer[];
|
||||
}
|
||||
|
||||
interface ApptPeriod {
|
||||
period: string;
|
||||
total: number;
|
||||
completed: number;
|
||||
cancelled: number;
|
||||
noShow: number;
|
||||
}
|
||||
|
||||
interface ServiceRow {
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
appointmentCount: number;
|
||||
completedCount: number;
|
||||
revenueCents: number;
|
||||
}
|
||||
|
||||
interface ChurnClient {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
lastAppointmentAt: string | null;
|
||||
}
|
||||
|
||||
interface ClientReport {
|
||||
newClients: { clientId: string; clientName: string; createdAt: string }[];
|
||||
activeInPeriodCount: number;
|
||||
churnRisk: ChurnClient[];
|
||||
churnRiskTotal: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtMoney(cents: number) {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | null) {
|
||||
if (!iso) return "Never";
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function toInputDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildQuery(from: string, to: string, extra: Record<string, string> = {}) {
|
||||
const params = new URLSearchParams({ from: `${from}T00:00:00Z`, to: `${to}T23:59:59Z`, ...extra });
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 10,
|
||||
padding: "1rem 1.25rem",
|
||||
flex: 1,
|
||||
minWidth: 140,
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: "#6b7280", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, margin: "0.25rem 0", color: "#111827" }}>{value}</div>
|
||||
{sub && <div style={{ fontSize: 12, color: "#9ca3af" }}>{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h2 style={{ fontSize: 15, fontWeight: 700, margin: "1.75rem 0 0.75rem", color: "#1a202c", borderBottom: "2px solid #e5e7eb", paddingBottom: "0.5rem" }}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
function Table({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) {
|
||||
return (
|
||||
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "#f8fafc" }}>
|
||||
{headers.map((h) => (
|
||||
<th key={h} style={{ textAlign: "left", padding: "0.5rem 0.75rem", borderBottom: "1px solid #e5e7eb", fontWeight: 600, fontSize: 11, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.04em" }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} style={{ borderBottom: "1px solid #f3f4f6" }}>
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} style={{ padding: "0.5rem 0.75rem", color: "#374151" }}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={headers.length} style={{ padding: "1.5rem 0.75rem", color: "#9ca3af", textAlign: "center" }}>
|
||||
No data for this period.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ReportsPage() {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
|
||||
const [fromDate, setFromDate] = useState(toInputDate(thirtyDaysAgo));
|
||||
const [toDate, setToDate] = useState(toInputDate(today));
|
||||
const [groupBy, setGroupBy] = useState<"day" | "week" | "month">("day");
|
||||
|
||||
const [summary, setSummary] = useState<Summary | null>(null);
|
||||
const [revenue, setRevenue] = useState<RevenueReport | null>(null);
|
||||
const [apptTrends, setApptTrends] = useState<ApptPeriod[]>([]);
|
||||
const [services, setServices] = useState<ServiceRow[]>([]);
|
||||
const [clientReport, setClientReport] = useState<ClientReport | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const qs = buildQuery(fromDate, toDate);
|
||||
const qsGroup = buildQuery(fromDate, toDate, { groupBy });
|
||||
|
||||
const [summRes, revRes, apptRes, svcRes, clientRes] = await Promise.all([
|
||||
fetch(`/api/reports/summary?${qs}`),
|
||||
fetch(`/api/reports/revenue?${qsGroup}`),
|
||||
fetch(`/api/reports/appointments?${qsGroup}`),
|
||||
fetch(`/api/reports/services?${qs}`),
|
||||
fetch(`/api/reports/clients?${qs}`),
|
||||
]);
|
||||
|
||||
const failures = [
|
||||
["summary", summRes],
|
||||
["revenue", revRes],
|
||||
["appointments", apptRes],
|
||||
["services", svcRes],
|
||||
["clients", clientRes],
|
||||
].filter(([, r]) => !(r as Response).ok);
|
||||
if (failures.length > 0) {
|
||||
const details = await Promise.all(
|
||||
failures.map(async ([name, r]) => {
|
||||
const res = r as Response;
|
||||
let body = "";
|
||||
try { body = await res.text(); } catch { /* ignore */ }
|
||||
return `${name} (HTTP ${res.status}${body ? `: ${body.slice(0, 120)}` : ""})`;
|
||||
})
|
||||
);
|
||||
throw new Error(`Failed to load report data — ${details.join(", ")}`);
|
||||
}
|
||||
|
||||
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
|
||||
summRes.ok ? summRes.json() as Promise<Summary> : summRes.text().then(() => { throw new Error("summary response not ok"); }),
|
||||
revRes.ok ? revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }> : revRes.text().then(() => { throw new Error("revenue response not ok"); }),
|
||||
apptRes.ok ? apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }> : apptRes.text().then(() => { throw new Error("appointments response not ok"); }),
|
||||
svcRes.ok ? svcRes.json() as Promise<{ rows: ServiceRow[] }> : svcRes.text().then(() => { throw new Error("services response not ok"); }),
|
||||
clientRes.ok ? clientRes.json() as Promise<ClientReport> : clientRes.text().then(() => { throw new Error("clients response not ok"); }),
|
||||
]);
|
||||
|
||||
setSummary(summData);
|
||||
setRevenue(revData);
|
||||
setApptTrends(apptData.byPeriod);
|
||||
setServices(svcData.rows);
|
||||
setClientReport(clientData);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadAll(); }, []); // run on mount only
|
||||
|
||||
function exportCsv(type: "revenue" | "appointments" | "services") {
|
||||
const qs = buildQuery(fromDate, toDate, { type });
|
||||
window.open(`/api/reports/export.csv?${qs}`, "_blank");
|
||||
}
|
||||
|
||||
const completionRate =
|
||||
summary && summary.appointments.total > 0
|
||||
? Math.round((summary.appointments.completed / summary.appointments.total) * 100)
|
||||
: 0;
|
||||
|
||||
const noShowRate =
|
||||
summary && summary.appointments.total > 0
|
||||
? Math.round((summary.appointments.noShow / summary.appointments.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif", maxWidth: 900 }}>
|
||||
{/* ── Controls ── */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", flexWrap: "wrap", marginBottom: "1.25rem" }}>
|
||||
<h1 style={{ margin: 0, fontSize: 22 }}>Reports</h1>
|
||||
<label style={{ fontSize: 13, color: "#374151" }}>
|
||||
From{" "}
|
||||
<input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ fontSize: 13, color: "#374151" }}>
|
||||
To{" "}
|
||||
<input
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(e) => setToDate(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ fontSize: 13, color: "#374151" }}>
|
||||
Group by{" "}
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.value as "day" | "week" | "month")}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="day">Day</option>
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
</select>
|
||||
</label>
|
||||
<button onClick={loadAll} style={{ ...btnStyle, background: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
|
||||
{loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: "0.5rem" }}>
|
||||
<button onClick={() => exportCsv("revenue")} style={btnStyle}>↓ Revenue CSV</button>
|
||||
<button onClick={() => exportCsv("appointments")} style={btnStyle}>↓ Appointments CSV</button>
|
||||
<button onClick={() => exportCsv("services")} style={btnStyle}>↓ Services CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: "#dc2626", padding: "0.75rem", background: "#fef2f2", borderRadius: 6 }}>{error}</p>}
|
||||
|
||||
{/* ── KPI Cards ── */}
|
||||
{summary && (
|
||||
<div style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap", marginBottom: "1rem" }}>
|
||||
<StatCard
|
||||
label="Revenue"
|
||||
value={fmtMoney(summary.revenue.totalCents)}
|
||||
sub={`${summary.revenue.paidInvoices} paid invoices`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Appointments"
|
||||
value={String(summary.appointments.total)}
|
||||
sub={`${completionRate}% completion rate`}
|
||||
/>
|
||||
<StatCard
|
||||
label="No-shows"
|
||||
value={String(summary.appointments.noShow)}
|
||||
sub={`${noShowRate}% of appointments`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cancellations"
|
||||
value={String(summary.appointments.cancelled)}
|
||||
/>
|
||||
<StatCard
|
||||
label="New Clients"
|
||||
value={String(summary.clients.new)}
|
||||
sub={`${summary.clients.total} total`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Revenue by Period ── */}
|
||||
<SectionHeader>Revenue by {groupBy.charAt(0).toUpperCase() + groupBy.slice(1)}</SectionHeader>
|
||||
<Table
|
||||
headers={["Period", "Invoices", "Revenue"]}
|
||||
rows={(revenue?.byPeriod ?? []).map((r) => [
|
||||
fmtDate(r.period),
|
||||
r.invoiceCount,
|
||||
fmtMoney(r.totalCents),
|
||||
])}
|
||||
/>
|
||||
|
||||
{/* ── Revenue by Groomer ── */}
|
||||
<SectionHeader>Revenue by Groomer</SectionHeader>
|
||||
<Table
|
||||
headers={["Groomer", "Invoices", "Revenue"]}
|
||||
rows={(revenue?.byGroomer ?? []).map((r) => [
|
||||
r.staffName,
|
||||
r.invoiceCount,
|
||||
fmtMoney(r.totalCents),
|
||||
])}
|
||||
/>
|
||||
|
||||
{/* ── Appointment Trends ── */}
|
||||
<SectionHeader>Appointment Trends by {groupBy.charAt(0).toUpperCase() + groupBy.slice(1)}</SectionHeader>
|
||||
<Table
|
||||
headers={["Period", "Total", "Completed", "Cancelled", "No-shows"]}
|
||||
rows={apptTrends.map((r) => [
|
||||
fmtDate(r.period),
|
||||
r.total,
|
||||
r.completed,
|
||||
r.cancelled,
|
||||
r.noShow,
|
||||
])}
|
||||
/>
|
||||
|
||||
{/* ── Service Popularity ── */}
|
||||
<SectionHeader>Service Popularity</SectionHeader>
|
||||
<Table
|
||||
headers={["Service", "Appointments", "Completed", "Revenue"]}
|
||||
rows={services.map((r) => [
|
||||
r.serviceName,
|
||||
r.appointmentCount,
|
||||
r.completedCount,
|
||||
fmtMoney(r.revenueCents),
|
||||
])}
|
||||
/>
|
||||
|
||||
{/* ── Client Retention ── */}
|
||||
<SectionHeader>Client Retention</SectionHeader>
|
||||
{clientReport && (
|
||||
<div style={{ marginBottom: "0.75rem", display: "flex", gap: "0.75rem", flexWrap: "wrap" }}>
|
||||
<StatCard label="New Clients" value={String(clientReport.newClients.length)} />
|
||||
<StatCard label="Active This Period" value={String(clientReport.activeInPeriodCount)} />
|
||||
<StatCard
|
||||
label="Churn Risk (90+ days inactive)"
|
||||
value={String(clientReport.churnRiskTotal)}
|
||||
sub="Clients with no recent visit"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SectionHeader>Churn Risk — Clients Without a Visit in 90+ Days</SectionHeader>
|
||||
<Table
|
||||
headers={["Client", "Last Appointment"]}
|
||||
rows={(clientReport?.churnRisk ?? []).map((r) => [
|
||||
r.clientName,
|
||||
fmtDate(r.lastAppointmentAt),
|
||||
])}
|
||||
/>
|
||||
{clientReport && clientReport.churnRiskTotal > 20 && (
|
||||
<p style={{ fontSize: 12, color: "#6b7280", marginTop: "0.25rem" }}>
|
||||
Showing top 20 of {clientReport.churnRiskTotal} at-risk clients.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared styles ────────────────────────────────────────────────────────────
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.4rem 0.85rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: "0.35rem 0.5rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
marginLeft: "0.25rem",
|
||||
};
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Service } from "@groombook/types";
|
||||
|
||||
interface ServiceForm {
|
||||
name: string;
|
||||
description: string;
|
||||
priceStr: string;
|
||||
durationMinutes: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: ServiceForm = {
|
||||
name: "",
|
||||
description: "",
|
||||
priceStr: "",
|
||||
durationMinutes: 60,
|
||||
active: true,
|
||||
};
|
||||
|
||||
export function ServicesPage() {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Service | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState<ServiceForm>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
const r = await fetch("/api/services?includeInactive=true");
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data = (await r.json()) as Service[];
|
||||
setServices(data);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function openNew() {
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function openEdit(s: Service) {
|
||||
setEditing(s);
|
||||
setForm({
|
||||
name: s.name,
|
||||
description: s.description ?? "",
|
||||
priceStr: (s.basePriceCents / 100).toFixed(2),
|
||||
durationMinutes: s.durationMinutes,
|
||||
active: s.active,
|
||||
});
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const price = parseFloat(form.priceStr);
|
||||
if (isNaN(price) || price <= 0) {
|
||||
setFormError("Price must be a positive number.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const body = {
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
basePriceCents: Math.round(price * 100),
|
||||
durationMinutes: form.durationMinutes,
|
||||
active: form.active,
|
||||
};
|
||||
const res = editing
|
||||
? await fetch(`/api/services/${editing.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
: await fetch("/api/services", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setShowForm(false);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setFormError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(s: Service) {
|
||||
setTogglingId(s.id);
|
||||
try {
|
||||
await fetch(`/api/services/${s.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ active: !s.active }),
|
||||
});
|
||||
await load();
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <p style={{ padding: "1rem" }}>Loading…</p>;
|
||||
if (error) return <p style={{ padding: "1rem", color: "red" }}>Error: {error}</p>;
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
|
||||
<h1 style={{ margin: 0 }}>Services</h1>
|
||||
<button
|
||||
onClick={openNew}
|
||||
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto" }}
|
||||
>
|
||||
+ Add Service
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{services.length === 0 ? (
|
||||
<p>No services configured yet.</p>
|
||||
) : (
|
||||
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "#f8fafc" }}>
|
||||
{["Name", "Description", "Price", "Duration", "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>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.map((s) => (
|
||||
<tr key={s.id} style={{ opacity: s.active ? 1 : 0.5 }}>
|
||||
<td style={tdStyle}>{s.name}</td>
|
||||
<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}>
|
||||
<button
|
||||
onClick={() => toggleActive(s)}
|
||||
disabled={togglingId === s.id}
|
||||
title={s.active ? "Deactivate" : "Activate"}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 36,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
border: "1px solid",
|
||||
borderColor: s.active ? "#10b981" : "#d1d5db",
|
||||
background: s.active ? "#d1fae5" : "#fff",
|
||||
cursor: togglingId === s.id ? "not-allowed" : "pointer",
|
||||
padding: 0,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
opacity: togglingId === s.id ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: "absolute",
|
||||
left: s.active ? 17 : 2,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
background: s.active ? "#10b981" : "#d1d5db",
|
||||
transition: "left 0.15s ease",
|
||||
}} />
|
||||
{togglingId === s.id && (
|
||||
<span style={{ position: "absolute", fontSize: 9, color: s.active ? "#065f46" : "#6b7280", fontWeight: 700 }}>…</span>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ ...tdStyle, whiteSpace: "nowrap" }}>
|
||||
<button onClick={() => openEdit(s)} style={{ ...btnStyle, marginRight: "0.4rem" }}>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<Modal onClose={() => setShowForm(false)}>
|
||||
<h2 style={{ marginTop: 0 }}>{editing ? "Edit Service" : "New Service"}</h2>
|
||||
<form onSubmit={submit}>
|
||||
<Field label="Name">
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Description (optional)">
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: "vertical" }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Price ($)">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
value={form.priceStr}
|
||||
onChange={(e) => setForm((f) => ({ ...f, priceStr: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Duration (minutes)">
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
step="5"
|
||||
value={form.durationMinutes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, durationMinutes: parseInt(e.target.value) || 60 }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.active}
|
||||
onChange={(e) => setForm((f) => ({ ...f, active: e.target.checked }))}
|
||||
/>
|
||||
Active (visible to booking form)
|
||||
</label>
|
||||
</Field>
|
||||
{formError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{formError}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
|
||||
>
|
||||
{saving ? "Saving…" : editing ? "Save Changes" : "Create Service"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
||||
maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" }}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.4rem 0.85rem", border: "1px solid #d1d5db",
|
||||
borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db",
|
||||
borderRadius: 6, fontSize: 14, boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6",
|
||||
};
|
||||
@@ -0,0 +1,791 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useBranding } from "../BrandingContext.js";
|
||||
|
||||
interface AuthProviderConfig {
|
||||
id: number;
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
issuerUrl: string;
|
||||
internalBaseUrl: string | null;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface AuthProviderForm {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
issuerUrl: string;
|
||||
internalBaseUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes: string;
|
||||
}
|
||||
|
||||
const REDACTED = "••••••••";
|
||||
|
||||
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||
|
||||
interface CurrentUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
isSuperUser: boolean;
|
||||
}
|
||||
|
||||
interface SettingsForm {
|
||||
businessName: string;
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
logoKey: string | null;
|
||||
logoUrl: string | null;
|
||||
logoBase64: string | null; // legacy
|
||||
logoMimeType: string | null; // legacy
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const { refresh } = useBranding();
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
|
||||
// Auth provider state
|
||||
const [authConfig, setAuthConfig] = useState<AuthProviderConfig | null>(null);
|
||||
const [authForm, setAuthForm] = useState<AuthProviderForm>({
|
||||
providerId: "authentik",
|
||||
displayName: "",
|
||||
issuerUrl: "",
|
||||
internalBaseUrl: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
scopes: "openid profile email",
|
||||
});
|
||||
const [authSecretTouched, setAuthSecretTouched] = useState(false);
|
||||
const [authLoaded, setAuthLoaded] = useState(false);
|
||||
const [authSaving, setAuthSaving] = useState(false);
|
||||
const [authMessage, setAuthMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; error?: string } | null>(null);
|
||||
const [showInternalBaseUrl, setShowInternalBaseUrl] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
|
||||
const [form, setForm] = useState<SettingsForm>({
|
||||
businessName: "",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoKey: null,
|
||||
logoUrl: null,
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/settings")
|
||||
.then((r) => r.json())
|
||||
.then(async (data) => {
|
||||
// The logo is now proxied through the API server so the browser
|
||||
// never receives an S3 URL — use the proxy path directly as the src.
|
||||
setForm({
|
||||
businessName: data.businessName ?? "GroomBook",
|
||||
primaryColor: data.primaryColor ?? "#4f8a6f",
|
||||
accentColor: data.accentColor ?? "#8b7355",
|
||||
logoKey: data.logoKey ?? null,
|
||||
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
|
||||
logoBase64: data.logoBase64 ?? null,
|
||||
logoMimeType: data.logoMimeType ?? null,
|
||||
});
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
// Load current user (for isSuperUser check) and auth provider config
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch("/api/staff/me").then((r) => r.json()).catch(() => null),
|
||||
fetch("/api/admin/auth-provider").then(async (r) => {
|
||||
if (r.ok) return r.json();
|
||||
if (r.status === 404) return null;
|
||||
throw new Error(`HTTP ${r.status}`);
|
||||
}).catch(() => null),
|
||||
]).then(([user, auth]) => {
|
||||
setCurrentUser(user as CurrentUser | null);
|
||||
if (auth) {
|
||||
setAuthConfig(auth as AuthProviderConfig);
|
||||
setAuthForm({
|
||||
providerId: (auth as AuthProviderConfig).providerId,
|
||||
displayName: (auth as AuthProviderConfig).displayName,
|
||||
issuerUrl: (auth as AuthProviderConfig).issuerUrl,
|
||||
internalBaseUrl: (auth as AuthProviderConfig).internalBaseUrl ?? "",
|
||||
clientId: (auth as AuthProviderConfig).clientId,
|
||||
clientSecret: (auth as AuthProviderConfig).clientSecret,
|
||||
scopes: (auth as AuthProviderConfig).scopes,
|
||||
});
|
||||
}
|
||||
setAuthLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 512 * 1024) {
|
||||
setMessage({ type: "error", text: "Logo must be under 512KB." });
|
||||
return;
|
||||
}
|
||||
|
||||
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setMessage({ type: "error", text: "Logo must be PNG, JPEG, GIF, or WebP." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Upload directly through the API server to avoid mixed-content issues
|
||||
// with pre-signed URLs that use the internal HTTP endpoint
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const uploadRes = await fetch("/api/admin/settings/logo/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
const err = await uploadRes.json().catch(() => null);
|
||||
throw new Error(err?.error ?? "Failed to upload logo");
|
||||
}
|
||||
const { logoKey } = await uploadRes.json();
|
||||
setForm((f) => ({ ...f, logoKey, logoUrl: `/api/admin/settings/logo?t=${Date.now()}`, logoBase64: null, logoMimeType: null }));
|
||||
setMessage({ type: "success", text: "Logo uploaded." });
|
||||
refresh();
|
||||
} catch (err: unknown) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Logo upload failed" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/admin/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.error ?? "Failed to save settings");
|
||||
}
|
||||
setMessage({ type: "success", text: "Settings saved." });
|
||||
refresh();
|
||||
} catch (err: unknown) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Save failed" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auth provider handlers
|
||||
const handleTestConnection = async () => {
|
||||
setTestingConnection(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/admin/auth-provider/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
issuerUrl: authForm.issuerUrl,
|
||||
...(authForm.internalBaseUrl ? { internalBaseUrl: authForm.internalBaseUrl } : {}),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setTestResult(data);
|
||||
} catch {
|
||||
setTestResult({ ok: false, error: "Network error. Please try again." });
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthSave = async () => {
|
||||
setAuthSaving(true);
|
||||
setAuthMessage(null);
|
||||
try {
|
||||
const payload: Record<string, string> = {
|
||||
providerId: authForm.providerId,
|
||||
displayName: authForm.displayName,
|
||||
issuerUrl: authForm.issuerUrl,
|
||||
clientId: authForm.clientId,
|
||||
scopes: authForm.scopes,
|
||||
};
|
||||
if (authForm.internalBaseUrl) {
|
||||
payload.internalBaseUrl = authForm.internalBaseUrl;
|
||||
}
|
||||
// Only send clientSecret if user changed it from the redacted value
|
||||
if (authSecretTouched) {
|
||||
payload.clientSecret = authForm.clientSecret;
|
||||
}
|
||||
const res = await fetch("/api/admin/auth-provider", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.error ?? "Failed to save auth provider");
|
||||
}
|
||||
const saved = await res.json() as AuthProviderConfig;
|
||||
setAuthConfig(saved);
|
||||
setAuthForm({
|
||||
providerId: saved.providerId,
|
||||
displayName: saved.displayName,
|
||||
issuerUrl: saved.issuerUrl,
|
||||
internalBaseUrl: saved.internalBaseUrl ?? "",
|
||||
clientId: saved.clientId,
|
||||
clientSecret: saved.clientSecret,
|
||||
scopes: saved.scopes,
|
||||
});
|
||||
setAuthSecretTouched(false);
|
||||
setAuthMessage({ type: "success", text: "Auth provider saved." });
|
||||
} catch (err: unknown) {
|
||||
setAuthMessage({ type: "error", text: err instanceof Error ? err.message : "Save failed" });
|
||||
} finally {
|
||||
setAuthSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetToEnvDefaults = async () => {
|
||||
if (!confirmReset) {
|
||||
setConfirmReset(true);
|
||||
return;
|
||||
}
|
||||
setConfirmReset(false);
|
||||
try {
|
||||
const res = await fetch("/api/admin/auth-provider", { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.error ?? "Failed to reset auth provider");
|
||||
}
|
||||
setAuthConfig(null);
|
||||
setAuthForm({
|
||||
providerId: "authentik",
|
||||
displayName: "",
|
||||
issuerUrl: "",
|
||||
internalBaseUrl: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
scopes: "openid profile email",
|
||||
});
|
||||
setAuthSecretTouched(false);
|
||||
setAuthMessage({ type: "success", text: "Auth provider reset to environment defaults." });
|
||||
} catch (err: unknown) {
|
||||
setAuthMessage({ type: "error", text: err instanceof Error ? err.message : "Reset failed" });
|
||||
}
|
||||
};
|
||||
|
||||
if (!loaded) return <p>Loading settings...</p>;
|
||||
|
||||
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType && ALLOWED_LOGO_TYPES.has(form.logoMimeType) ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600 }}>
|
||||
<h1>Branding & Appearance</h1>
|
||||
<p style={{ color: "#6b7280", marginBottom: "1.5rem" }}>
|
||||
Customize your business name, logo, and color scheme.
|
||||
</p>
|
||||
|
||||
{/* Business Name */}
|
||||
<div style={{ marginBottom: "1.25rem" }}>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: 4 }}>
|
||||
Business Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.businessName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, businessName: e.target.value }))}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem 0.75rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div style={{ marginBottom: "1.25rem" }}>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: 4 }}>
|
||||
Logo
|
||||
</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
{logoSrc ? (
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="Logo preview"
|
||||
style={{ width: 64, height: 64, objectFit: "contain", borderRadius: 8, border: "1px solid #e5e7eb" }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: 8,
|
||||
border: "2px dashed #d1d5db", display: "flex",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
color: "#9ca3af", fontSize: 12,
|
||||
}}>
|
||||
No logo
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
padding: "0.4rem 0.75rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Upload Logo
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={handleLogoChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
{logoSrc && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/settings/logo", { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.error ?? "Failed to delete logo");
|
||||
}
|
||||
setForm((f) => ({ ...f, logoKey: null, logoUrl: null, logoBase64: null, logoMimeType: null }));
|
||||
setMessage({ type: "success", text: "Logo removed." });
|
||||
refresh();
|
||||
} catch (err: unknown) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Delete failed" });
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
padding: "0.4rem 0.75rem",
|
||||
border: "1px solid #fca5a5",
|
||||
borderRadius: 6,
|
||||
background: "#fff",
|
||||
color: "#dc2626",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<p style={{ fontSize: 12, color: "#9ca3af", marginTop: 4 }}>
|
||||
PNG, SVG, JPEG, or WebP. Max 512KB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Pickers */}
|
||||
<div style={{ display: "flex", gap: "1.5rem", marginBottom: "1.5rem" }}>
|
||||
<div>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: 4 }}>
|
||||
Primary Color
|
||||
</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="color"
|
||||
value={form.primaryColor}
|
||||
onChange={(e) => setForm((f) => ({ ...f, primaryColor: e.target.value }))}
|
||||
style={{ width: 40, height: 40, border: "none", cursor: "pointer" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.primaryColor}
|
||||
onChange={(e) => setForm((f) => ({ ...f, primaryColor: e.target.value }))}
|
||||
style={{
|
||||
width: 90,
|
||||
padding: "0.4rem 0.5rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: 4 }}>
|
||||
Accent Color
|
||||
</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="color"
|
||||
value={form.accentColor}
|
||||
onChange={(e) => setForm((f) => ({ ...f, accentColor: e.target.value }))}
|
||||
style={{ width: 40, height: 40, border: "none", cursor: "pointer" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.accentColor}
|
||||
onChange={(e) => setForm((f) => ({ ...f, accentColor: e.target.value }))}
|
||||
style={{
|
||||
width: 90,
|
||||
padding: "0.4rem 0.5rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div style={{
|
||||
padding: "1rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
marginBottom: "1.5rem",
|
||||
background: "#fafafa",
|
||||
}}>
|
||||
<p style={{ fontWeight: 600, marginBottom: 8, fontSize: 13, color: "#6b7280" }}>Preview</p>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "0.5rem 1rem",
|
||||
background: "#fff",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #e5e7eb",
|
||||
}}>
|
||||
{logoSrc && (
|
||||
<img src={logoSrc} alt="" style={{ width: 28, height: 28, objectFit: "contain" }} />
|
||||
)}
|
||||
<strong style={{ color: form.primaryColor }}>{form.businessName}</strong>
|
||||
<span style={{
|
||||
marginLeft: "auto",
|
||||
padding: "0.25rem 0.75rem",
|
||||
borderRadius: 4,
|
||||
color: "#fff",
|
||||
background: form.primaryColor,
|
||||
fontSize: 13,
|
||||
}}>
|
||||
Button
|
||||
</span>
|
||||
<span style={{
|
||||
padding: "0.25rem 0.75rem",
|
||||
borderRadius: 4,
|
||||
color: "#fff",
|
||||
background: form.accentColor,
|
||||
fontSize: 13,
|
||||
}}>
|
||||
Accent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
{message && (
|
||||
<div style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderRadius: 6,
|
||||
marginBottom: "1rem",
|
||||
fontSize: 14,
|
||||
background: message.type === "success" ? "#ecfdf5" : "#fef2f2",
|
||||
color: message.type === "success" ? "#065f46" : "#991b1b",
|
||||
border: `1px solid ${message.type === "success" ? "#a7f3d0" : "#fecaca"}`,
|
||||
}}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.businessName.trim()}
|
||||
style={{
|
||||
padding: "0.5rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: form.primaryColor,
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: saving ? "wait" : "pointer",
|
||||
opacity: saving ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
|
||||
{/* Auth Provider Section — super users only */}
|
||||
{currentUser?.isSuperUser && (
|
||||
<>
|
||||
<hr style={{ margin: "2rem 0", border: "none", borderTop: "1px solid #e5e7eb" }} />
|
||||
<h2>Authentication Provider</h2>
|
||||
<p style={{ color: "#6b7280", marginBottom: "1rem" }}>
|
||||
Configure the SSO provider for sign-in. Changes require a service restart.
|
||||
</p>
|
||||
|
||||
{/* Warning banner */}
|
||||
<div style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderRadius: 6,
|
||||
marginBottom: "1rem",
|
||||
fontSize: 13,
|
||||
background: "#fef3c7",
|
||||
color: "#92400e",
|
||||
border: "1px solid #fde68a",
|
||||
}}>
|
||||
⚠️ Changing auth settings will require a service restart. Active sessions will be preserved.
|
||||
</div>
|
||||
|
||||
{/* Environment config banner */}
|
||||
{!authConfig && authLoaded && (
|
||||
<div style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderRadius: 6,
|
||||
marginBottom: "1rem",
|
||||
fontSize: 13,
|
||||
background: "#eff6ff",
|
||||
color: "#1e40af",
|
||||
border: "1px solid #bfdbfe",
|
||||
}}>
|
||||
Currently using environment configuration (no DB config set).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!authLoaded && <p style={{ color: "#6b7280", fontSize: 14 }}>Loading auth provider...</p>}
|
||||
|
||||
{authLoaded && (
|
||||
<>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.875rem", marginBottom: "1rem" }}>
|
||||
{/* Provider ID */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Provider ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authForm.providerId}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, providerId: e.target.value }))}
|
||||
placeholder="e.g. authentik, okta"
|
||||
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Display Name */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authForm.displayName}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, displayName: e.target.value }))}
|
||||
placeholder="e.g. Company SSO"
|
||||
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Issuer URL */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>
|
||||
Issuer URL
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<input
|
||||
type="url"
|
||||
value={authForm.issuerUrl}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, issuerUrl: e.target.value }))}
|
||||
placeholder="https://your-idp.example.com"
|
||||
style={{ flex: 1, padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleTestConnection}
|
||||
disabled={testingConnection || !authForm.issuerUrl.trim() || !authForm.clientId.trim()}
|
||||
style={{
|
||||
padding: "0.5rem 0.875rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #d1d5db",
|
||||
background: "#fff",
|
||||
cursor: testingConnection || !authForm.issuerUrl.trim() || !authForm.clientId.trim() ? "not-allowed" : "pointer",
|
||||
fontSize: 13,
|
||||
opacity: testingConnection || !authForm.issuerUrl.trim() || !authForm.clientId.trim() ? 0.6 : 1,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{testingConnection ? "Testing..." : "Test Connection"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
background: testResult.ok ? "#ecfdf5" : "#fef2f2",
|
||||
color: testResult.ok ? "#065f46" : "#991b1b",
|
||||
border: `1px solid ${testResult.ok ? "#a7f3d0" : "#fecaca"}`,
|
||||
}}>
|
||||
{testResult.ok ? "✓ Connection successful" : `✗ ${testResult.error}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Internal Base URL — collapsible */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowInternalBaseUrl((v) => !v)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
color: "#4b5563",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{showInternalBaseUrl ? "▾" : "▸"} Internal Base URL
|
||||
<span style={{ fontSize: 11, color: "#9ca3af", fontWeight: 400 }}>(optional — hairpin NAT)</span>
|
||||
</button>
|
||||
{showInternalBaseUrl && (
|
||||
<input
|
||||
type="url"
|
||||
value={authForm.internalBaseUrl}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, internalBaseUrl: e.target.value }))}
|
||||
placeholder="http://host.docker.internal:9080"
|
||||
style={{ marginTop: 4, width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Client ID */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Client ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authForm.clientId}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, clientId: e.target.value }))}
|
||||
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client Secret */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Client Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
value={authSecretTouched ? authForm.clientSecret : (authForm.clientSecret === REDACTED ? "" : authForm.clientSecret)}
|
||||
onChange={(e) => {
|
||||
setAuthSecretTouched(true);
|
||||
setAuthForm((f) => ({ ...f, clientSecret: e.target.value }));
|
||||
}}
|
||||
placeholder={authConfig ? "(unchanged)" : "Required"}
|
||||
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||
/>
|
||||
{authConfig && !authSecretTouched && (
|
||||
<p style={{ fontSize: 12, color: "#9ca3af", marginTop: 2 }}>Leave blank to keep existing secret.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scopes */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Scopes</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authForm.scopes}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, scopes: e.target.value }))}
|
||||
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth messages */}
|
||||
{authMessage && (
|
||||
<div style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderRadius: 6,
|
||||
marginBottom: "1rem",
|
||||
fontSize: 14,
|
||||
background: authMessage.type === "success" ? "#ecfdf5" : "#fef2f2",
|
||||
color: authMessage.type === "success" ? "#065f46" : "#991b1b",
|
||||
border: `1px solid ${authMessage.type === "success" ? "#a7f3d0" : "#fecaca"}`,
|
||||
}}>
|
||||
{authMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={handleAuthSave}
|
||||
disabled={authSaving || !authForm.providerId.trim() || !authForm.issuerUrl.trim() || !authForm.clientId.trim()}
|
||||
style={{
|
||||
padding: "0.5rem 1.25rem",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: "#4f8a6f",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: authSaving || !authForm.providerId.trim() || !authForm.issuerUrl.trim() || !authForm.clientId.trim() ? "not-allowed" : "pointer",
|
||||
opacity: authSaving ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{authSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetToEnvDefaults}
|
||||
style={{
|
||||
padding: "0.5rem 1.25rem",
|
||||
borderRadius: 6,
|
||||
border: confirmReset ? "1px solid #dc2626" : "1px solid #d1d5db",
|
||||
background: confirmReset ? "#fef2f2" : "#fff",
|
||||
color: confirmReset ? "#dc2626" : "#6b7280",
|
||||
fontWeight: 500,
|
||||
fontSize: 14,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{confirmReset ? "Confirm Reset to Env Defaults?" : "Reset to Environment Defaults"}
|
||||
</button>
|
||||
{confirmReset && (
|
||||
<button
|
||||
onClick={() => setConfirmReset(false)}
|
||||
style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #d1d5db",
|
||||
background: "#fff",
|
||||
color: "#6b7280",
|
||||
fontSize: 14,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export { SetupWizard } from "./SetupWizard.tsx";
|
||||
@@ -0,0 +1,503 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useBranding } from "../BrandingContext.js";
|
||||
|
||||
interface SetupStatus {
|
||||
showAuthProviderStep?: boolean;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface AuthFormState {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
issuerUrl: string;
|
||||
internalBaseUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes: string;
|
||||
}
|
||||
|
||||
interface Step {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const { refresh: refreshBranding } = useBranding();
|
||||
|
||||
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
|
||||
const [loadingStatus, setLoadingStatus] = useState(true);
|
||||
|
||||
const [authForm, setAuthForm] = useState<AuthFormState>({
|
||||
providerId: "authentik",
|
||||
displayName: "",
|
||||
issuerUrl: "",
|
||||
internalBaseUrl: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
scopes: "openid profile email",
|
||||
});
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [businessName, setBusinessName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/setup/status")
|
||||
.then((r) => r.json() as Promise<SetupStatus>)
|
||||
.then((data) => {
|
||||
setSetupStatus(data);
|
||||
setLoadingStatus(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoadingStatus(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const STEPS: Step[] = setupStatus?.showAuthProviderStep
|
||||
? [
|
||||
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
||||
{ id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." },
|
||||
{ id: "business", title: "Business Name", description: "What is the name of your business?" },
|
||||
{ id: "superuser", title: "Super User", description: "You will be designated as a Super User with full administrative access." },
|
||||
{ id: "admin", title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." },
|
||||
{ id: "done", title: "All Set!", description: "Your GroomBook instance is ready to use." },
|
||||
]
|
||||
: [
|
||||
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
||||
{ id: "business", title: "Business Name", description: "What is the name of your business?" },
|
||||
{ id: "superuser", title: "Super User", description: "You will be designated as a Super User with full administrative access." },
|
||||
{ id: "admin", title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." },
|
||||
{ id: "done", title: "All Set!", description: "Your GroomBook instance is ready to use." },
|
||||
];
|
||||
|
||||
const current = STEPS[step];
|
||||
const isLast = step === STEPS.length - 1;
|
||||
const isFirst = step === 0;
|
||||
const canGoBack = step > 0 && step < STEPS.length - 1;
|
||||
|
||||
const canGoNext = (() => {
|
||||
if (step === STEPS.length - 1) return true;
|
||||
if (current?.id === "business") return businessName.trim().length > 0;
|
||||
if (current?.id === "auth") {
|
||||
return (
|
||||
authForm.displayName.trim().length > 0 &&
|
||||
authForm.issuerUrl.trim().length > 0 &&
|
||||
authForm.clientId.trim().length > 0 &&
|
||||
authForm.clientSecret.trim().length > 0
|
||||
);
|
||||
}
|
||||
return true;
|
||||
})();
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestingConnection(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/setup/auth-provider/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerId: authForm.providerId,
|
||||
displayName: authForm.displayName,
|
||||
issuerUrl: authForm.issuerUrl,
|
||||
internalBaseUrl: authForm.internalBaseUrl || null,
|
||||
clientId: authForm.clientId,
|
||||
scopes: authForm.scopes,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as TestResult;
|
||||
setTestResult(data);
|
||||
} catch {
|
||||
setTestResult({ ok: false, error: "Network error. Please try again." });
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (step === STEPS.length - 1) {
|
||||
navigate("/admin");
|
||||
return;
|
||||
}
|
||||
|
||||
if (current?.id === "auth") {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/setup/auth-provider", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerId: authForm.providerId,
|
||||
displayName: authForm.displayName,
|
||||
issuerUrl: authForm.issuerUrl,
|
||||
internalBaseUrl: authForm.internalBaseUrl || null,
|
||||
clientId: authForm.clientId,
|
||||
clientSecret: authForm.clientSecret,
|
||||
scopes: authForm.scopes,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json()) as { error?: string };
|
||||
setError(data.error || "Failed to save auth provider configuration. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (current?.id === "business" && businessName.trim()) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ businessName: businessName.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json()) as { error?: string };
|
||||
setError(data.error || "Setup failed. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
refreshBranding();
|
||||
if (onSetupComplete) onSetupComplete();
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setStep((s) => s + 1);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 0) setStep((s) => s - 1);
|
||||
};
|
||||
|
||||
if (loadingStatus) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#f0f2f5",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}>
|
||||
<p style={{ color: "#6b7280" }}>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "0.6rem 0.85rem",
|
||||
borderRadius: 8,
|
||||
border: "1px solid #d1d5db",
|
||||
fontSize: 15,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
marginBottom: error ? "0.5rem" : 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#f0f2f5",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}>
|
||||
<div style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.10)",
|
||||
padding: "2.5rem 3rem",
|
||||
maxWidth: 480,
|
||||
width: "100%",
|
||||
}}>
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
||||
{STEPS.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: i === step ? "#4f8a6f" : i < step ? "#4f8a6f" : "#e2e8f0",
|
||||
opacity: i === step ? 1 : i < step ? 0.5 : 1,
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
||||
Step {step + 1} of {STEPS.length}
|
||||
</p>
|
||||
|
||||
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
||||
{current?.title}
|
||||
</h2>
|
||||
|
||||
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
||||
{current?.description}
|
||||
</p>
|
||||
|
||||
{current?.id === "business" && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Happy Paws Grooming"
|
||||
value={businessName}
|
||||
onChange={(e) => setBusinessName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && canGoNext && void handleNext()}
|
||||
autoFocus
|
||||
style={inputStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{current?.id === "auth" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Provider ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. authentik"
|
||||
value={authForm.providerId}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, providerId: e.target.value }))}
|
||||
style={{ ...inputStyle, fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Company SSO"
|
||||
value={authForm.displayName}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, displayName: e.target.value }))}
|
||||
style={{ ...inputStyle, fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Issuer URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://auth.example.com"
|
||||
value={authForm.issuerUrl}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, issuerUrl: e.target.value }))}
|
||||
style={{ ...inputStyle, fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://auth.internal.example.com"
|
||||
value={authForm.internalBaseUrl}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, internalBaseUrl: e.target.value }))}
|
||||
style={{ ...inputStyle, fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Client ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your OAuth client ID"
|
||||
value={authForm.clientId}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, clientId: e.target.value }))}
|
||||
style={{ ...inputStyle, fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Client Secret
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Your OAuth client secret"
|
||||
value={authForm.clientSecret}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, clientSecret: e.target.value }))}
|
||||
style={{ ...inputStyle, fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Scopes
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="openid profile email"
|
||||
value={authForm.scopes}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, scopes: e.target.value }))}
|
||||
style={{ ...inputStyle, fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void handleTestConnection(); }}
|
||||
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
|
||||
style={{
|
||||
padding: "0.45rem 0.85rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #d1d5db",
|
||||
background: "#fff",
|
||||
color: "#374151",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: testingConnection || !authForm.issuerUrl || !authForm.clientId ? "not-allowed" : "pointer",
|
||||
opacity: testingConnection || !authForm.issuerUrl || !authForm.clientId ? 0.6 : 1,
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
{testingConnection ? "Testing..." : "Test Connection"}
|
||||
</button>
|
||||
|
||||
{testResult && (
|
||||
<div style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
background: testResult.ok ? "#ecfdf5" : "#fef2f2",
|
||||
color: testResult.ok ? "#065f46" : "#991b1b",
|
||||
border: `1px solid ${testResult.ok ? "#a7f3d0" : "#fecaca"}`,
|
||||
}}>
|
||||
{testResult.ok
|
||||
? "Connection successful!"
|
||||
: `Connection failed: ${testResult.error}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current?.id === "superuser" && (
|
||||
<div style={{
|
||||
background: "#f0fdf4",
|
||||
border: "1px solid #bbf7d0",
|
||||
borderRadius: 8,
|
||||
padding: "0.85rem 1rem",
|
||||
fontSize: 14,
|
||||
color: "#166534",
|
||||
marginBottom: "1rem",
|
||||
}}>
|
||||
As a Super User, you can manage all settings, staff, and appointments.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current?.id === "admin" && (
|
||||
<div style={{
|
||||
background: "#fffbeb",
|
||||
border: "1px solid #fde68a",
|
||||
borderRadius: 8,
|
||||
padding: "0.85rem 1rem",
|
||||
fontSize: 14,
|
||||
color: "#92400e",
|
||||
}}>
|
||||
You can add additional Super Users from the Staff management page after setup.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p style={{
|
||||
margin: "0.5rem 0 0",
|
||||
fontSize: 13,
|
||||
color: "#dc2626",
|
||||
background: "#fef2f2",
|
||||
border: "1px solid #fecaca",
|
||||
borderRadius: 6,
|
||||
padding: "0.5rem 0.75rem",
|
||||
}}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
marginTop: current?.id === "auth" ? "1.25rem" : current?.id === "admin" ? "1.5rem" : "1.25rem",
|
||||
justifyContent: isFirst ? "flex-end" : "space-between",
|
||||
}}>
|
||||
{canGoBack && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: "0.55rem 1.1rem",
|
||||
borderRadius: 8,
|
||||
border: "1px solid #d1d5db",
|
||||
background: "#fff",
|
||||
color: "#374151",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { void handleNext(); }}
|
||||
disabled={(!canGoNext && !isLast) || loading}
|
||||
style={{
|
||||
padding: "0.55rem 1.25rem",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: canGoNext && !loading ? "#4f8a6f" : "#9ca3af",
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: canGoNext && !loading ? "pointer" : "not-allowed",
|
||||
opacity: loading ? 0.7 : 1,
|
||||
marginLeft: canGoBack ? 0 : "auto",
|
||||
}}
|
||||
>
|
||||
{loading
|
||||
? "Setting up..."
|
||||
: isLast
|
||||
? "Go to Dashboard"
|
||||
: current?.id === "business" || current?.id === "auth"
|
||||
? "Continue"
|
||||
: "Next"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Staff } from "@groombook/types";
|
||||
|
||||
interface StaffForm {
|
||||
name: string;
|
||||
email: string;
|
||||
role: "groomer" | "receptionist" | "manager";
|
||||
}
|
||||
|
||||
const EMPTY_FORM: StaffForm = { name: "", email: "", role: "groomer" };
|
||||
|
||||
export function StaffPage() {
|
||||
const [staff, setStaff] = useState<Staff[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<Staff | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Staff | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState<StaffForm>(EMPTY_FORM);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
const [toggleError, setToggleError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
const [staffRes, meRes] = await Promise.all([
|
||||
fetch("/api/staff?includeInactive=true"),
|
||||
fetch("/api/staff/me"),
|
||||
]);
|
||||
if (!staffRes.ok) throw new Error(`HTTP ${staffRes.status}`);
|
||||
if (!meRes.ok) throw new Error(`HTTP ${meRes.status}`);
|
||||
setStaff((await staffRes.json()) as Staff[]);
|
||||
setCurrentUser((await meRes.json()) as Staff);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function openNew() {
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function openEdit(s: Staff) {
|
||||
setEditing(s);
|
||||
setForm({ name: s.name, email: s.email, role: s.role });
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const res = editing
|
||||
? await fetch(`/api/staff/${editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: form.name, role: form.role }) })
|
||||
: await fetch("/api/staff", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setShowForm(false);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setFormError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(s: Staff) {
|
||||
setTogglingId(s.id);
|
||||
setToggleError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/staff/${s.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ active: !s.active }) });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
setToggleError(err.error ?? `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSuperUser(s: Staff) {
|
||||
setTogglingId(s.id);
|
||||
setToggleError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/staff/${s.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isSuperUser: !s.isSuperUser }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
setToggleError(err.error ?? `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const isLastSuperUser = (s: Staff) =>
|
||||
s.isSuperUser && staff.filter((st) => st.isSuperUser).length === 1;
|
||||
|
||||
if (loading) return <p style={{ padding: "1rem" }}>Loading…</p>;
|
||||
if (error) return <p style={{ padding: "1rem", color: "red" }}>Error: {error}</p>;
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
|
||||
<h1 style={{ margin: 0 }}>Staff</h1>
|
||||
<button onClick={openNew} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto" }}>
|
||||
+ Add Staff
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{toggleError && (
|
||||
<p style={{ color: "red", marginBottom: "0.5rem" }}>{toggleError}</p>
|
||||
)}
|
||||
|
||||
{staff.length === 0 ? (
|
||||
<p>No staff members yet.</p>
|
||||
) : (
|
||||
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "#f8fafc" }}>
|
||||
{["Name", "Email", "Role", "Super User", "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>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{staff.map((s) => (
|
||||
<tr key={s.id} style={{ opacity: s.active ? 1 : 0.5 }}>
|
||||
<td style={tdStyle}>{s.name}</td>
|
||||
<td style={tdStyle}>{s.email}</td>
|
||||
<td style={tdStyle}><span style={{ textTransform: "capitalize" }}>{s.role}</span></td>
|
||||
<td style={tdStyle}>
|
||||
{currentUser?.isSuperUser ? (
|
||||
<button
|
||||
onClick={() => toggleSuperUser(s)}
|
||||
disabled={togglingId === s.id || isLastSuperUser(s)}
|
||||
title={isLastSuperUser(s) ? "Cannot revoke the last super user" : s.isSuperUser ? "Revoke super user" : "Grant super user"}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 36,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
border: "1px solid",
|
||||
borderColor: s.isSuperUser ? "#f59e0b" : "#d1d5db",
|
||||
background: s.isSuperUser ? "#fef3c7" : "#fff",
|
||||
cursor: togglingId === s.id || isLastSuperUser(s) ? "not-allowed" : "pointer",
|
||||
padding: 0,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
opacity: togglingId === s.id || isLastSuperUser(s) ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: "absolute",
|
||||
left: s.isSuperUser ? 17 : 2,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
background: s.isSuperUser ? "#f59e0b" : "#d1d5db",
|
||||
transition: "left 0.15s ease",
|
||||
}} />
|
||||
{togglingId === s.id && (
|
||||
<span style={{ position: "absolute", fontSize: 9, color: "#92400e", fontWeight: 700 }}>…</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
s.isSuperUser ? (
|
||||
<span style={{ padding: "2px 8px", borderRadius: 12, fontSize: 11, fontWeight: 600, background: "#fef3c7", color: "#92400e" }}>Super User</span>
|
||||
) : (
|
||||
<span style={{ color: "#9ca3af", fontSize: 13 }}>—</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<button
|
||||
onClick={() => toggleActive(s)}
|
||||
disabled={togglingId === s.id || isLastSuperUser(s)}
|
||||
title={isLastSuperUser(s) ? "Cannot deactivate the last super user" : s.active ? "Deactivate" : "Activate"}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 36,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
border: "1px solid",
|
||||
borderColor: s.active ? "#10b981" : "#d1d5db",
|
||||
background: s.active ? "#d1fae5" : "#fff",
|
||||
cursor: togglingId === s.id || isLastSuperUser(s) ? "not-allowed" : "pointer",
|
||||
padding: 0,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
opacity: togglingId === s.id || isLastSuperUser(s) ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: "absolute",
|
||||
left: s.active ? 17 : 2,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
background: s.active ? "#10b981" : "#d1d5db",
|
||||
transition: "left 0.15s ease",
|
||||
}} />
|
||||
{togglingId === s.id && (
|
||||
<span style={{ position: "absolute", fontSize: 9, color: s.active ? "#065f46" : "#6b7280", fontWeight: 700 }}>…</span>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ ...tdStyle, whiteSpace: "nowrap" }}>
|
||||
<button onClick={() => openEdit(s)} style={btnStyle}>Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowForm(false); }}
|
||||
>
|
||||
<div style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 400, width: "calc(100% - 2rem)", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}>
|
||||
<h2 style={{ marginTop: 0 }}>{editing ? "Edit Staff" : "New Staff Member"}</h2>
|
||||
<form onSubmit={submit}>
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={labelStyle}>Full name</label>
|
||||
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||
</div>
|
||||
{!editing && (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={labelStyle}>Email</label>
|
||||
<input type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} required style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={labelStyle}>Role</label>
|
||||
<select value={form.role} onChange={(e) => setForm((f) => ({ ...f, role: e.target.value as StaffForm["role"] }))} style={inputStyle}>
|
||||
<option value="groomer">Groomer</option>
|
||||
<option value="receptionist">Receptionist</option>
|
||||
<option value="manager">Manager</option>
|
||||
</select>
|
||||
</div>
|
||||
{formError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{formError}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button type="submit" disabled={saving} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
|
||||
{saving ? "Saving…" : editing ? "Save Changes" : "Add Staff"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = { padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500 };
|
||||
const inputStyle: React.CSSProperties = { width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, boxSizing: "border-box" };
|
||||
const labelStyle: React.CSSProperties = { display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" };
|
||||
const tdStyle: React.CSSProperties = { padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6" };
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Filter, Loader } from "lucide-react";
|
||||
import type { ImpersonationAuditLog } from "@groombook/types";
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AuditLogViewer({ sessionId, onClose }: Props) {
|
||||
const [auditLog, setAuditLog] = useState<ImpersonationAuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterAction, setFilterAction] = useState<string>("all");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`/api/impersonation/sessions/${sessionId}/audit-log`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`Failed to load audit log (${r.status})`);
|
||||
return r.json() as Promise<ImpersonationAuditLog[]>;
|
||||
})
|
||||
.then((logs) => {
|
||||
// API returns newest-first; reverse for chronological display
|
||||
setAuditLog([...logs].reverse());
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to load audit log");
|
||||
setLoading(false);
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
const actionTypes = ["all", ...new Set(auditLog.map((e) => e.action))];
|
||||
const filtered = filterAction === "all" ? auditLog : auditLog.filter((e) => e.action === filterAction);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stone-200">
|
||||
<h2 className="font-semibold text-stone-800">Impersonation Audit Log</h2>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-stone-100 rounded-lg">
|
||||
<X size={18} className="text-stone-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="px-6 py-3 border-b border-stone-100 flex items-center gap-2">
|
||||
<Filter size={14} className="text-stone-400" />
|
||||
<select
|
||||
value={filterAction}
|
||||
onChange={(e) => setFilterAction(e.target.value)}
|
||||
className="text-sm border border-stone-200 rounded-lg px-2 py-1"
|
||||
>
|
||||
{actionTypes.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a === "all" ? "All actions" : a.replace(/_/g, " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-stone-400 ml-auto">{filtered.length} entries</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-3">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-stone-400">
|
||||
<Loader size={16} className="animate-spin" />
|
||||
<span className="text-sm">Loading audit log…</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center py-8">{error}</p>
|
||||
)}
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<p className="text-sm text-stone-400 text-center py-8">No audit entries</p>
|
||||
)}
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{filtered.map((entry) => (
|
||||
<div key={entry.id} className="flex gap-3 text-sm">
|
||||
<div className="text-xs text-stone-400 whitespace-nowrap pt-0.5 w-20 shrink-0">
|
||||
{new Date(entry.createdAt).toLocaleTimeString()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-block px-2 py-0.5 bg-stone-100 text-stone-600 rounded text-xs font-medium mb-0.5">
|
||||
{entry.action.replace(/_/g, " ")}
|
||||
</span>
|
||||
{entry.pageVisited && (
|
||||
<p className="text-stone-700">{entry.pageVisited}</p>
|
||||
)}
|
||||
{entry.metadata && Object.keys(entry.metadata).length > 0 && (
|
||||
<p className="text-stone-500 text-xs">
|
||||
{JSON.stringify(entry.metadata)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useSearchParams, Navigate } from "react-router-dom";
|
||||
import {
|
||||
Home, Calendar, PawPrint, FileText, CreditCard, MessageSquare,
|
||||
Settings, LogOut, Shield,
|
||||
} from "lucide-react";
|
||||
import { Dashboard } from "./sections/Dashboard.js";
|
||||
import { AppointmentsSection, RescheduleFlow } from "./sections/Appointments.js";
|
||||
import { PetProfiles } from "./sections/PetProfiles.js";
|
||||
import { ReportCards } from "./sections/ReportCards.js";
|
||||
import { BillingPayments } from "./sections/BillingPayments.js";
|
||||
import { Communication } from "./sections/Communication.js";
|
||||
import { AccountSettings } from "./sections/AccountSettings.js";
|
||||
import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
||||
import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||
import { useBranding } from "../BrandingContext.js";
|
||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||
import type { ImpersonationSession } from "@groombook/types";
|
||||
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
||||
|
||||
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
|
||||
|
||||
const NAV_ITEMS: { id: Section; label: string; icon: typeof Home }[] = [
|
||||
{ id: "dashboard", label: "Home", icon: Home },
|
||||
{ id: "appointments", label: "Appointments", icon: Calendar },
|
||||
{ id: "pets", label: "My Pets", icon: PawPrint },
|
||||
{ id: "reports", label: "Report Cards", icon: FileText },
|
||||
{ id: "billing", label: "Billing", icon: CreditCard },
|
||||
{ id: "messages", label: "Messages", icon: MessageSquare },
|
||||
{ id: "settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
export function CustomerPortal() {
|
||||
const [activeSection, setActiveSection] = useState<Section>("dashboard");
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||
const [showReschedule, setShowReschedule] = useState(false);
|
||||
const [rescheduleAppointment, setRescheduleAppointment] = useState<PortalAppointment | null>(null);
|
||||
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
||||
const [sessionExtended, setSessionExtended] = useState(false);
|
||||
const [clientName, setClientName] = useState<string>("");
|
||||
const [initComplete, setInitComplete] = useState(false);
|
||||
// Track whether an impersonation session fetch from URL param is in-flight
|
||||
// Dashboard will not redirect while this is true, allowing the session to load
|
||||
const [isImpersonating, setIsImpersonating] = useState(false);
|
||||
const { branding } = useBranding();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// On mount: load session from ?sessionId= URL param OR from dev user in localStorage
|
||||
const initDone = useRef(false);
|
||||
useEffect(() => {
|
||||
if (initDone.current) return;
|
||||
initDone.current = true;
|
||||
|
||||
const sessionId = searchParams.get("sessionId");
|
||||
|
||||
if (sessionId) {
|
||||
setIsImpersonating(true);
|
||||
// Real impersonation session from URL param
|
||||
fetch(`/api/impersonation/sessions/${sessionId}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) return null;
|
||||
return r.json() as Promise<ImpersonationSession>;
|
||||
})
|
||||
.then((s) => {
|
||||
if (s && s.status === "active") {
|
||||
setSession(s);
|
||||
fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data?.name) setClientName(data.name); })
|
||||
.catch(() => {});
|
||||
}
|
||||
setSearchParams({}, { replace: true });
|
||||
})
|
||||
.catch(() => {
|
||||
setSearchParams({}, { replace: true });
|
||||
})
|
||||
.finally(() => { setInitComplete(true); setIsImpersonating(false); });
|
||||
return;
|
||||
}
|
||||
|
||||
// Dev mode: check for dev user in localStorage and create a dev session
|
||||
const devUser = getDevUser();
|
||||
if (devUser && devUser.type === "client") {
|
||||
fetch("/api/portal/dev-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: devUser.id }),
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) return null;
|
||||
return r.json() as Promise<ImpersonationSession>;
|
||||
})
|
||||
.then((s) => {
|
||||
if (s && s.id) {
|
||||
setSession(s);
|
||||
setClientName(devUser.name);
|
||||
}
|
||||
})
|
||||
.finally(() => setInitComplete(true));
|
||||
} else {
|
||||
// No valid session: staff dev users and unauthenticated users fall through here
|
||||
setInitComplete(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEnd = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
await fetch(`/api/impersonation/sessions/${session.id}/end`, { method: "POST" });
|
||||
} catch {
|
||||
// Ignore — session ends on the client regardless
|
||||
}
|
||||
setSession(null);
|
||||
setSessionExtended(false);
|
||||
window.location.href = "/admin/clients";
|
||||
}, [session]);
|
||||
|
||||
const handleExtend = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
const r = await fetch(`/api/impersonation/sessions/${session.id}/extend`, { method: "POST" });
|
||||
if (r.ok) {
|
||||
const updated = await r.json() as ImpersonationSession;
|
||||
setSession(updated);
|
||||
setSessionExtended(true);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const logPageView = useCallback((page: string) => {
|
||||
if (!session) return;
|
||||
void fetch(`/api/impersonation/sessions/${session.id}/log`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "page_view", pageVisited: page }),
|
||||
});
|
||||
}, [session]);
|
||||
|
||||
const handleNavClick = (section: Section) => {
|
||||
setActiveSection(section);
|
||||
setMobileNavOpen(false);
|
||||
if (session?.status === "active") {
|
||||
logPageView(section);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReschedule = useCallback((appointmentId: string) => {
|
||||
// Look up the full appointment from Dashboard's displayed data
|
||||
// The appointment was already fetched by Dashboard, so we use the ID to find it
|
||||
setRescheduleAppointment({ id: appointmentId } as PortalAppointment);
|
||||
setShowReschedule(true);
|
||||
}, []);
|
||||
|
||||
const isReadOnly = session?.status === "active";
|
||||
|
||||
const renderSection = () => {
|
||||
const sessionId = session?.id ?? null;
|
||||
switch (activeSection) {
|
||||
case "dashboard":
|
||||
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} isImpersonating={isImpersonating} />;
|
||||
case "appointments":
|
||||
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||
case "pets":
|
||||
return <PetProfiles readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||
case "reports":
|
||||
return <ReportCards sessionId={sessionId} />;
|
||||
case "billing":
|
||||
return <BillingPayments readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||
case "messages":
|
||||
return <Communication readOnly={!!isReadOnly} />;
|
||||
case "settings":
|
||||
return <AccountSettings readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||
}
|
||||
};
|
||||
|
||||
const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase();
|
||||
|
||||
// After init completes, redirect unauthenticated users to /login and staff to /admin.
|
||||
// The portal chrome must NEVER be visible to users without a valid client session.
|
||||
// For client dev users, we stay on the portal even if session is null — the dev-session
|
||||
// response may not have id set immediately, or there may be timing issues with the
|
||||
// session state. Dev users are verified via localStorage and the dev-session flow.
|
||||
if (initComplete && !session) {
|
||||
const devUser = getDevUser();
|
||||
if (devUser && devUser.type === "staff") {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
if (devUser && devUser.type === "client") {
|
||||
// Don't redirect — dev session creation may have failed or session.id may be null
|
||||
// The portal should still render for client dev users
|
||||
} else {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-[#faf8f5] font-sans"
|
||||
style={session?.status === "active" ? { border: "3px solid #f59e0b" } : undefined}
|
||||
>
|
||||
{session?.status === "active" && (
|
||||
<>
|
||||
<ImpersonationBanner
|
||||
session={session}
|
||||
isExtended={sessionExtended}
|
||||
onEnd={() => { void handleEnd(); }}
|
||||
onExtend={() => { void handleExtend(); }}
|
||||
onShowAudit={() => setShowAuditLog(true)}
|
||||
/>
|
||||
{/* Watermark */}
|
||||
<div className="fixed inset-0 pointer-events-none z-10 flex items-center justify-center opacity-[0.04]">
|
||||
<div className="text-8xl font-bold text-amber-900 -rotate-45 select-none tracking-widest">
|
||||
STAFF VIEW
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showAuditLog && session && (
|
||||
<AuditLogViewer
|
||||
sessionId={session.id}
|
||||
onClose={() => setShowAuditLog(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showReschedule && rescheduleAppointment && (
|
||||
<RescheduleFlow
|
||||
appointment={rescheduleAppointment}
|
||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||
sessionId={session?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Header */}
|
||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-stone-200">
|
||||
<button
|
||||
onClick={() => setMobileNavOpen(!mobileNavOpen)}
|
||||
className="p-2 text-stone-600 hover:text-stone-900"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={mobileNavOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-lg font-semibold text-stone-800">{branding.businessName}</span>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
||||
{avatarInitials}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex">
|
||||
{/* Sidebar Navigation */}
|
||||
<nav className={`
|
||||
${mobileNavOpen ? "translate-x-0" : "-translate-x-full"}
|
||||
md:translate-x-0 fixed md:sticky top-0 left-0 z-30
|
||||
w-64 h-screen bg-white border-r border-stone-200
|
||||
flex flex-col transition-transform duration-200
|
||||
`}>
|
||||
<div className="hidden md:flex items-center gap-3 px-6 py-5 border-b border-stone-100">
|
||||
{branding.logoBase64 && branding.logoMimeType ? (
|
||||
<img
|
||||
src={`data:${branding.logoMimeType};base64,${branding.logoBase64}`}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-xl object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg" style={{ background: branding.accentColor }}>
|
||||
🐾
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-semibold text-stone-800 text-sm">{branding.businessName}</div>
|
||||
<div className="text-xs text-stone-500">Grooming</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 py-4 px-3 space-y-1 overflow-y-auto">
|
||||
{NAV_ITEMS.map(({ id, label, icon: Icon }) => {
|
||||
const active = id === activeSection;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => handleNavClick(id)}
|
||||
className={`
|
||||
w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors
|
||||
${active
|
||||
? "bg-stone-100 text-stone-800 font-semibold"
|
||||
: "text-stone-600 hover:bg-stone-50 hover:text-stone-900"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Session controls (only shown during active impersonation) */}
|
||||
<div className="border-t border-stone-100 p-4 space-y-2">
|
||||
{session?.status === "active" && (
|
||||
<button
|
||||
onClick={() => { void handleEnd(); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-red-700 bg-red-50 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
End Impersonation
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs text-stone-400">
|
||||
<Shield size={12} />
|
||||
Customer Portal v1.0
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile nav overlay */}
|
||||
{mobileNavOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 z-20 md:hidden"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-h-screen overflow-x-hidden">
|
||||
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-stone-800">
|
||||
{NAV_ITEMS.find(n => n.id === activeSection)?.label}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-stone-600">Hi, {clientName.split(" ")[0] || "Guest"}</span>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
||||
{avatarInitials}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 md:p-8 max-w-6xl w-full overflow-hidden">
|
||||
{renderSection()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Eye, Clock, LogOut, FileSearch } from "lucide-react";
|
||||
import type { ImpersonationSession } from "@groombook/types";
|
||||
|
||||
interface Props {
|
||||
session: ImpersonationSession;
|
||||
isExtended: boolean;
|
||||
onEnd: () => void;
|
||||
onExtend: () => void;
|
||||
onShowAudit: () => void;
|
||||
}
|
||||
|
||||
export function ImpersonationBanner({ session, isExtended, onEnd, onExtend, onShowAudit }: Props) {
|
||||
const [remaining, setRemaining] = useState("");
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
const now = Date.now();
|
||||
const expires = new Date(session.expiresAt).getTime();
|
||||
const diff = expires - now;
|
||||
if (diff <= 0) {
|
||||
setRemaining("Expired");
|
||||
onEnd();
|
||||
return;
|
||||
}
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const secs = Math.floor((diff % 60000) / 1000);
|
||||
setRemaining(`${mins}:${secs.toString().padStart(2, "0")}`);
|
||||
setShowWarning(mins < 5);
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [session.expiresAt, onEnd]);
|
||||
|
||||
return (
|
||||
<div data-testid="impersonation-banner" className="sticky top-0 z-40 bg-amber-500 text-amber-950 px-4 py-2.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm font-medium shadow-md">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Eye size={16} />
|
||||
STAFF VIEW
|
||||
</span>
|
||||
{session.reason && (
|
||||
<span className="hidden md:inline text-amber-800 text-xs">
|
||||
Reason: {session.reason}
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden sm:inline text-amber-800 text-xs">
|
||||
Started {new Date(session.startedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<span className={`flex items-center gap-1 text-xs ${showWarning ? "text-red-800 font-bold animate-pulse" : "text-amber-800"}`}>
|
||||
<Clock size={14} />
|
||||
{remaining}
|
||||
</span>
|
||||
{showWarning && !isExtended && (
|
||||
<button
|
||||
onClick={onExtend}
|
||||
className="px-2 py-1 text-xs bg-amber-600 text-white rounded hover:bg-amber-700"
|
||||
>
|
||||
Extend
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onShowAudit}
|
||||
className="px-2 py-1 text-xs bg-amber-100 text-amber-800 rounded hover:bg-amber-200 flex items-center gap-1"
|
||||
>
|
||||
<FileSearch size={12} />
|
||||
Audit
|
||||
</button>
|
||||
<button
|
||||
onClick={onEnd}
|
||||
className="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center gap-1"
|
||||
>
|
||||
<LogOut size={12} />
|
||||
End Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
export interface Pet {
|
||||
id: string;
|
||||
name: string;
|
||||
breed: string;
|
||||
weight: number;
|
||||
dob: string;
|
||||
sex: "male" | "female";
|
||||
spayedNeutered: boolean;
|
||||
photo: string;
|
||||
allergies: string;
|
||||
skinConditions: string;
|
||||
anxietyTriggers: string;
|
||||
aggressionNotes: string;
|
||||
mobilityIssues: string;
|
||||
medications: string;
|
||||
preferredCut: string;
|
||||
shampooPreference: string;
|
||||
sensitiveAreas: string;
|
||||
standingInstructions: string;
|
||||
vaccinations: Vaccination[];
|
||||
}
|
||||
|
||||
export interface Vaccination {
|
||||
name: string;
|
||||
lastAdministered: string;
|
||||
expirationDate: string;
|
||||
status: "valid" | "expiring" | "expired";
|
||||
documentUploaded: boolean;
|
||||
}
|
||||
|
||||
export interface Appointment {
|
||||
id: string;
|
||||
petId: string;
|
||||
petName: string;
|
||||
groomerId: string;
|
||||
groomerName: string;
|
||||
services: string[];
|
||||
addOns: string[];
|
||||
date: string;
|
||||
time: string;
|
||||
duration: number;
|
||||
price: number;
|
||||
status: "confirmed" | "pending" | "waitlisted" | "completed" | "cancelled";
|
||||
confirmationStatus: "pending" | "confirmed" | "cancelled";
|
||||
notes: string;
|
||||
customerNotes: string;
|
||||
reportCardId?: string;
|
||||
}
|
||||
|
||||
export interface ReportCard {
|
||||
id: string;
|
||||
appointmentId: string;
|
||||
petName: string;
|
||||
groomerName: string;
|
||||
date: string;
|
||||
servicesPerformed: string[];
|
||||
behaviorMood: "calm" | "anxious" | "wiggly" | "cooperative";
|
||||
conditionObservations: string[];
|
||||
groomerNote: string;
|
||||
nextRecommendedDate: string;
|
||||
beforeDescription: string;
|
||||
afterDescription: string;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
status: "paid" | "outstanding" | "overdue";
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
sender: "customer" | "business";
|
||||
senderName: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
priceRange: string;
|
||||
isAddOn: boolean;
|
||||
}
|
||||
|
||||
export interface Groomer {
|
||||
id: string;
|
||||
name: string;
|
||||
specialties: string[];
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface LoyaltyInfo {
|
||||
points: number;
|
||||
nextRewardAt: number;
|
||||
rewardName: string;
|
||||
}
|
||||
|
||||
export const GROOMERS: Groomer[] = [
|
||||
{ id: "g1", name: "Jamie", specialties: ["Large breeds", "Dematting"], avatar: "🧑🎨" },
|
||||
{ id: "g2", name: "Alex", specialties: ["Small breeds", "Creative cuts"], avatar: "💇" },
|
||||
{ id: "g3", name: "Morgan", specialties: ["Anxious pets", "Cats"], avatar: "✂️" },
|
||||
];
|
||||
|
||||
export const SERVICES: Service[] = [
|
||||
{ id: "s1", name: "Bath & Brush", description: "Full bath, blow-dry, and brush-out", duration: 45, priceRange: "$45–$65", isAddOn: false },
|
||||
{ id: "s2", name: "Full Groom", description: "Bath, haircut, nail trim, ear cleaning", duration: 90, priceRange: "$75–$120", isAddOn: false },
|
||||
{ id: "s3", name: "Puppy's First Groom", description: "Gentle introduction to grooming for puppies under 6 months", duration: 60, priceRange: "$55–$70", isAddOn: false },
|
||||
{ id: "s4", name: "Nail Trim", description: "Quick nail trim and file", duration: 15, priceRange: "$15–$20", isAddOn: false },
|
||||
{ id: "s5", name: "Teeth Brushing", description: "Enzymatic toothpaste brushing", duration: 10, priceRange: "$10–$15", isAddOn: true },
|
||||
{ id: "s6", name: "Nail Grinding", description: "Smooth finish with a Dremel tool", duration: 15, priceRange: "$12–$18", isAddOn: true },
|
||||
{ id: "s7", name: "De-shedding Treatment", description: "Specialized undercoat removal and conditioning", duration: 30, priceRange: "$25–$40", isAddOn: true },
|
||||
{ id: "s8", name: "Blueberry Facial", description: "Gentle face wash with brightening blueberry formula", duration: 10, priceRange: "$8–$12", isAddOn: true },
|
||||
];
|
||||
|
||||
export const PETS: Pet[] = [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Biscuit",
|
||||
breed: "Golden Retriever",
|
||||
weight: 65,
|
||||
dob: "2022-01-15",
|
||||
sex: "male",
|
||||
spayedNeutered: true,
|
||||
photo: "🐕",
|
||||
allergies: "None known",
|
||||
skinConditions: "Mild dry skin in winter",
|
||||
anxietyTriggers: "None — very calm",
|
||||
aggressionNotes: "None",
|
||||
mobilityIssues: "None",
|
||||
medications: "Monthly heartworm prevention",
|
||||
preferredCut: "Teddy bear cut",
|
||||
shampooPreference: "Oatmeal-based (sensitive skin)",
|
||||
sensitiveAreas: "Ears — prone to irritation",
|
||||
standingInstructions: "Extra gentle around ears. Likes treats during nail trim.",
|
||||
vaccinations: [
|
||||
{ name: "Rabies", lastAdministered: "2025-06-10", expirationDate: "2028-06-10", status: "valid", documentUploaded: true },
|
||||
{ name: "DHPP", lastAdministered: "2025-08-20", expirationDate: "2026-08-20", status: "valid", documentUploaded: true },
|
||||
{ name: "Bordetella", lastAdministered: "2025-09-01", expirationDate: "2026-09-01", status: "valid", documentUploaded: true },
|
||||
{ name: "Leptospirosis", lastAdministered: "2025-08-20", expirationDate: "2026-08-20", status: "valid", documentUploaded: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
name: "Mochi",
|
||||
breed: "Shih Tzu",
|
||||
weight: 12,
|
||||
dob: "2024-02-28",
|
||||
sex: "female",
|
||||
spayedNeutered: true,
|
||||
photo: "🐩",
|
||||
allergies: "Chicken-based products",
|
||||
skinConditions: "None",
|
||||
anxietyTriggers: "Loud dryers, nail clipping",
|
||||
aggressionNotes: "May nip during nail trimming",
|
||||
mobilityIssues: "None",
|
||||
medications: "None",
|
||||
preferredCut: "Puppy cut — even length all over",
|
||||
shampooPreference: "Hypoallergenic",
|
||||
sensitiveAreas: "Paws — very sensitive to handling",
|
||||
standingInstructions: "Use quiet dryer setting. Take breaks during nail trim. Distract with peanut butter mat.",
|
||||
vaccinations: [
|
||||
{ name: "Rabies", lastAdministered: "2025-04-15", expirationDate: "2026-04-15", status: "valid", documentUploaded: true },
|
||||
{ name: "DHPP", lastAdministered: "2025-04-15", expirationDate: "2026-04-15", status: "valid", documentUploaded: true },
|
||||
{ name: "Bordetella", lastAdministered: "2025-06-28", expirationDate: "2026-03-28", status: "expiring", documentUploaded: true },
|
||||
{ name: "Leptospirosis", lastAdministered: "2025-04-15", expirationDate: "2026-04-15", status: "valid", documentUploaded: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const UPCOMING_APPOINTMENTS: Appointment[] = [
|
||||
{
|
||||
id: "a1", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
||||
services: ["Full Groom"], addOns: ["De-shedding Treatment"],
|
||||
date: "2026-03-21", time: "10:00 AM", duration: 120, price: 145,
|
||||
status: "confirmed", confirmationStatus: "confirmed", notes: "Spring shed is heavy — extra undercoat work needed",
|
||||
customerNotes: "",
|
||||
},
|
||||
{
|
||||
id: "a2", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan",
|
||||
services: ["Full Groom"], addOns: ["Teeth Brushing"],
|
||||
date: "2026-03-25", time: "2:00 PM", duration: 100, price: 90,
|
||||
status: "confirmed", confirmationStatus: "confirmed", notes: "First visit with Morgan — patient with anxious pets",
|
||||
customerNotes: "",
|
||||
},
|
||||
{
|
||||
id: "a3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
||||
services: ["Bath & Brush"], addOns: [],
|
||||
date: "2026-04-18", time: "11:00 AM", duration: 45, price: 55,
|
||||
status: "pending", confirmationStatus: "pending", notes: "",
|
||||
customerNotes: "",
|
||||
},
|
||||
];
|
||||
|
||||
export const PAST_APPOINTMENTS: Appointment[] = [
|
||||
{
|
||||
id: "pa1", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
||||
services: ["Full Groom"], addOns: ["De-shedding Treatment", "Blueberry Facial"],
|
||||
date: "2026-02-15", time: "10:00 AM", duration: 130, price: 160,
|
||||
status: "completed", confirmationStatus: "confirmed", notes: "", reportCardId: "rc1",
|
||||
customerNotes: "",
|
||||
},
|
||||
{
|
||||
id: "pa2", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex",
|
||||
services: ["Full Groom"], addOns: ["Teeth Brushing"],
|
||||
date: "2026-02-20", time: "1:00 PM", duration: 100, price: 88,
|
||||
status: "completed", confirmationStatus: "confirmed", notes: "", reportCardId: "rc2",
|
||||
customerNotes: "",
|
||||
},
|
||||
{
|
||||
id: "pa3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
||||
services: ["Bath & Brush"], addOns: [],
|
||||
date: "2026-01-18", time: "9:00 AM", duration: 45, price: 55,
|
||||
status: "completed", confirmationStatus: "confirmed", notes: "",
|
||||
customerNotes: "",
|
||||
},
|
||||
{
|
||||
id: "pa4", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex",
|
||||
services: ["Puppy's First Groom"], addOns: [],
|
||||
date: "2026-01-10", time: "3:00 PM", duration: 60, price: 62,
|
||||
status: "completed", confirmationStatus: "confirmed", notes: "",
|
||||
customerNotes: "",
|
||||
},
|
||||
{
|
||||
id: "pa5", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
||||
services: ["Full Groom"], addOns: ["Nail Grinding"],
|
||||
date: "2025-12-20", time: "10:00 AM", duration: 105, price: 132,
|
||||
status: "completed", confirmationStatus: "confirmed", notes: "Holiday groom",
|
||||
customerNotes: "",
|
||||
},
|
||||
{
|
||||
id: "pa6", petId: "p1", petName: "Biscuit", groomerId: "g2", groomerName: "Alex",
|
||||
services: ["Full Groom"], addOns: [],
|
||||
date: "2025-11-15", time: "11:00 AM", duration: 90, price: 110,
|
||||
status: "completed", confirmationStatus: "confirmed", notes: "",
|
||||
customerNotes: "",
|
||||
},
|
||||
{
|
||||
id: "pa7", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan",
|
||||
services: ["Bath & Brush"], addOns: [],
|
||||
date: "2025-11-08", time: "2:00 PM", duration: 45, price: 48,
|
||||
status: "completed", confirmationStatus: "confirmed", notes: "",
|
||||
customerNotes: "",
|
||||
},
|
||||
{
|
||||
id: "pa8", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
||||
services: ["Bath & Brush"], addOns: ["De-shedding Treatment"],
|
||||
date: "2025-10-12", time: "10:00 AM", duration: 75, price: 85,
|
||||
status: "completed", confirmationStatus: "confirmed", notes: "",
|
||||
customerNotes: "",
|
||||
},
|
||||
];
|
||||
|
||||
export const REPORT_CARDS: ReportCard[] = [
|
||||
{
|
||||
id: "rc1",
|
||||
appointmentId: "pa1",
|
||||
petName: "Biscuit",
|
||||
groomerName: "Jamie",
|
||||
date: "2026-02-15",
|
||||
servicesPerformed: ["Full Groom", "De-shedding Treatment", "Blueberry Facial"],
|
||||
behaviorMood: "calm",
|
||||
conditionObservations: [
|
||||
"Mild ear wax buildup — recommend ear cleaning solution at home",
|
||||
"Slight matting behind ears — addressed during groom",
|
||||
"Coat and skin in great overall condition",
|
||||
],
|
||||
groomerNote: "Biscuit was an absolute angel today as always! His coat came out beautifully after the de-shedding treatment. The blueberry facial really brightened up his face. He got extra treats for being the best boy. See you next month!",
|
||||
nextRecommendedDate: "2026-03-21",
|
||||
beforeDescription: "Thick winter coat with moderate shedding, minor matting behind ears, slightly dull facial fur",
|
||||
afterDescription: "Fluffy teddy bear cut, smooth and tangle-free, bright clean face, nails trimmed short",
|
||||
},
|
||||
{
|
||||
id: "rc2",
|
||||
appointmentId: "pa2",
|
||||
petName: "Mochi",
|
||||
groomerName: "Alex",
|
||||
date: "2026-02-20",
|
||||
servicesPerformed: ["Full Groom", "Teeth Brushing"],
|
||||
behaviorMood: "anxious",
|
||||
conditionObservations: [
|
||||
"Tear staining around eyes — may benefit from daily wipe routine",
|
||||
"Slight tartar buildup on back molars — consider dental checkup",
|
||||
"Paw pads healthy, no cracking",
|
||||
],
|
||||
groomerNote: "Mochi was a little nervous today but did so well! We took it slow with the dryer on low setting and gave plenty of breaks. She started to relax halfway through. The teeth brushing went smoothly. She's getting more comfortable each visit — such a brave girl!",
|
||||
nextRecommendedDate: "2026-03-25",
|
||||
beforeDescription: "Overgrown puppy cut, tear staining visible, coat slightly tangled around legs",
|
||||
afterDescription: "Even puppy cut all over, tear stains cleaned, smooth silky coat, fresh and fluffy",
|
||||
},
|
||||
];
|
||||
|
||||
export const INVOICES: Invoice[] = [
|
||||
{ id: "inv1", date: "2026-02-20", amount: 88, status: "outstanding", items: ["Mochi — Full Groom", "Teeth Brushing"] },
|
||||
{ id: "inv2", date: "2026-02-15", amount: 160, status: "paid", items: ["Biscuit — Full Groom", "De-shedding Treatment", "Blueberry Facial"] },
|
||||
{ id: "inv3", date: "2026-01-18", amount: 55, status: "paid", items: ["Biscuit — Bath & Brush"] },
|
||||
{ id: "inv4", date: "2026-01-10", amount: 62, status: "paid", items: ["Mochi — Puppy's First Groom"] },
|
||||
{ id: "inv5", date: "2025-12-20", amount: 132, status: "paid", items: ["Biscuit — Full Groom", "Nail Grinding"] },
|
||||
];
|
||||
|
||||
export const MESSAGES: Message[] = [
|
||||
{ id: "m1", sender: "customer", senderName: "Sarah", text: "Hi! Can Biscuit get the same cut as last time on the 21st?", timestamp: "2026-03-16T10:30:00Z", read: true },
|
||||
{ id: "m2", sender: "business", senderName: "Paws & Reflect", text: "Absolutely, Sarah! Jamie has Biscuit's teddy bear cut notes on file. We'll make sure he looks just as handsome. See you Saturday!", timestamp: "2026-03-16T11:15:00Z", read: true },
|
||||
{ id: "m3", sender: "customer", senderName: "Sarah", text: "Perfect, thanks! Also, Mochi's Bordetella is expiring soon — should I get that updated before her appointment on the 25th?", timestamp: "2026-03-17T09:00:00Z", read: true },
|
||||
{ id: "m4", sender: "business", senderName: "Paws & Reflect", text: "Great question! Yes, we require current Bordetella for all grooms. As long as it's updated before the 25th, you're all set. You can upload the new certificate through your pet profile once you have it.", timestamp: "2026-03-17T09:45:00Z", read: false },
|
||||
];
|
||||
|
||||
export const LOYALTY: LoyaltyInfo = {
|
||||
points: 340,
|
||||
nextRewardAt: 500,
|
||||
rewardName: "Free Bath & Brush",
|
||||
};
|
||||
|
||||
export const CUSTOMER = {
|
||||
name: "Sarah Mitchell",
|
||||
email: "sarah.mitchell@email.com",
|
||||
phone: "(555) 234-5678",
|
||||
address: "142 Maple Lane, Portland, OR 97201",
|
||||
};
|
||||
|
||||
export const BUSINESS_NAME = "Paws & Reflect Grooming";
|
||||
|
||||
export const SAVED_PAYMENT_METHODS = [
|
||||
{ id: "pm1", type: "visa", last4: "4242", expiry: "09/27", isDefault: true },
|
||||
{ id: "pm2", type: "mastercard", last4: "8888", expiry: "03/28", isDefault: false },
|
||||
];
|
||||
|
||||
export const SIGNED_AGREEMENTS = [
|
||||
{ id: "wa1", name: "Liability Waiver", dateSigned: "2025-09-15" },
|
||||
{ id: "wa2", name: "Service Agreement", dateSigned: "2025-09-15" },
|
||||
{ id: "wa3", name: "Photo Release", dateSigned: "2025-09-15" },
|
||||
];
|
||||
|
||||
export const PREPAID_PACKAGES = [
|
||||
{ id: "pkg1", name: "5-Groom Bundle", totalCredits: 5, usedCredits: 3, expiresAt: "2026-09-15" },
|
||||
];
|
||||
@@ -0,0 +1,347 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
||||
import { PetForm } from "./PetForm.js";
|
||||
import { authClient } from "../../lib/auth-client.js";
|
||||
|
||||
interface Props {
|
||||
sessionId: string | null;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
interface PersonalInfoData {
|
||||
id?: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
interface PetData {
|
||||
id: string;
|
||||
name: string;
|
||||
species?: string;
|
||||
breed?: string;
|
||||
weight?: number;
|
||||
photo?: string;
|
||||
}
|
||||
|
||||
export function AccountSettings({ sessionId, readOnly }: Props) {
|
||||
const [tab, setTab] = useState<"personal" | "password" | "pets" | "agreements">("personal");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{([
|
||||
{ id: "personal" as const, label: "Personal Info", icon: User },
|
||||
{ id: "password" as const, label: "Password", icon: Lock },
|
||||
{ id: "pets" as const, label: "Manage Pets", icon: PawPrint },
|
||||
{ id: "agreements" as const, label: "Agreements", icon: FileCheck },
|
||||
]).map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "personal" && <PersonalInfo sessionId={sessionId} readOnly={readOnly} />}
|
||||
{tab === "password" && <PasswordChange readOnly={readOnly} />}
|
||||
{tab === "pets" && <ManagePets sessionId={sessionId} readOnly={readOnly} />}
|
||||
{tab === "agreements" && <Agreements />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalInfo({ sessionId, readOnly }: { sessionId: string | null; readOnly: boolean }) {
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPersonalInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/portal/me", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data: PersonalInfoData = await response.json();
|
||||
setForm({
|
||||
name: [data.firstName, data.lastName].filter(Boolean).join(" ") || "",
|
||||
email: data.email || "",
|
||||
phone: data.phone || "",
|
||||
address: data.address || "",
|
||||
});
|
||||
} else {
|
||||
setError("Failed to load personal info");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load personal info");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPersonalInfo();
|
||||
}, [sessionId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<p className="text-sm text-stone-500">Loading personal info...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<h3 className="font-medium text-stone-800 mb-4">Personal Information</h3>
|
||||
<div className="space-y-4 max-w-md">
|
||||
{([
|
||||
{ key: "name" as const, label: "Full Name", type: "text" },
|
||||
{ key: "email" as const, label: "Email", type: "email" },
|
||||
{ key: "phone" as const, label: "Phone", type: "tel" },
|
||||
{ key: "address" as const, label: "Address", type: "text" },
|
||||
]).map(({ key, label, type }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={form[key]}
|
||||
onChange={e => !readOnly && setForm({ ...form, [key]: e.target.value })}
|
||||
disabled={readOnly}
|
||||
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm disabled:bg-stone-50 disabled:text-stone-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
|
||||
Save Changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const passwordsMatch = newPassword === confirmPassword;
|
||||
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading;
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<p className="text-sm text-stone-500">Password changes are not available during staff impersonation.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (authClient as any).changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? "Failed to change password.");
|
||||
} else {
|
||||
setSuccess(true);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTimeout(() => setSuccess(false), 4000);
|
||||
}
|
||||
} catch {
|
||||
setError("An unexpected error occurred.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<h3 className="font-medium text-stone-800 mb-4">Change Password</h3>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Updating..." : "Update Password"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagePets({ sessionId, readOnly }: { sessionId: string | null; readOnly: boolean }) {
|
||||
const [pets, setPets] = useState<PetData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/portal/pets", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPets(Array.isArray(data) ? data : []);
|
||||
} else {
|
||||
setError("Failed to load pets");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load pets");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPets();
|
||||
}, [sessionId]);
|
||||
|
||||
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? undefined : undefined;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<p className="text-sm text-stone-500">Loading pets...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (editingPet || showAddForm) {
|
||||
return (
|
||||
<PetForm
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
pet={(editingPet ?? undefined) as any}
|
||||
onSave={() => { setEditingPetId(null); setShowAddForm(false); }}
|
||||
onCancel={() => { setEditingPetId(null); setShowAddForm(false); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{pets.map(pet => (
|
||||
<div key={pet.id} className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-(--color-accent-light) flex items-center justify-center text-3xl">
|
||||
{pet.photo}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-stone-800">{pet.name}</p>
|
||||
<p className="text-sm text-stone-500">{pet.breed} · {pet.weight} lbs</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingPetId(pet.id)}
|
||||
className="px-3 py-1.5 border border-stone-200 rounded-lg text-xs text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button className="p-1.5 border border-stone-200 rounded-lg text-stone-400 hover:text-amber-600 hover:border-amber-200">
|
||||
<Archive size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-500 hover:border-(--color-accent) hover:text-(--color-accent-dark) transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add New Pet
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Agreements() {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<p className="text-sm text-stone-500">
|
||||
No agreements found. There is currently no agreements table in the database.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,578 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
||||
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
status: "pending" | "paid" | "failed" | "refunded";
|
||||
totalCents: number;
|
||||
date: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface PaymentMethod {
|
||||
id: string;
|
||||
brand: string;
|
||||
last4: string;
|
||||
expiryMonth: number;
|
||||
expiryYear: number;
|
||||
}
|
||||
|
||||
interface BillingPaymentsProps {
|
||||
sessionId: string | null;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||
const [packages] = useState<{ name: string; remaining: number }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
||||
const [autopay, setAutopay] = useState(false);
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
const [publishableKey, setPublishableKey] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!sessionId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [configRes, invoicesRes, methodsRes] = await Promise.all([
|
||||
fetch("/api/portal/config", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
||||
}),
|
||||
fetch("/api/portal/invoices", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
||||
}),
|
||||
fetch("/api/portal/payment-methods", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!configRes.ok) throw new Error("Failed to fetch config");
|
||||
const configData = await configRes.json();
|
||||
setPublishableKey(configData.stripePublishableKey ?? "");
|
||||
|
||||
const invoicesData = await invoicesRes.json();
|
||||
setInvoices(Array.isArray(invoicesData) ? invoicesData : invoicesData.invoices || []);
|
||||
|
||||
if (methodsRes.ok) {
|
||||
const methodsData = await methodsRes.json();
|
||||
setPaymentMethods(
|
||||
(methodsData ?? []).map((m: { id: string; card: { brand: string; last4: string; exp_month: number; exp_year: number } }) => ({
|
||||
id: m.id,
|
||||
brand: m.card?.brand ?? "unknown",
|
||||
last4: m.card?.last4 ?? "****",
|
||||
expiryMonth: m.card?.exp_month ?? 0,
|
||||
expiryYear: m.card?.exp_year ?? 0,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [sessionId]);
|
||||
|
||||
const formatCents = (cents: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
||||
|
||||
const pending = invoices.filter((i) => i.status === "pending");
|
||||
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3" />
|
||||
<div className="h-24 bg-gray-200 rounded" />
|
||||
<div className="h-24 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-red-600">Error: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{totalPending > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-stone-500">Outstanding Balance</p>
|
||||
<p className="text-3xl font-bold text-stone-800">{formatCents(totalPending)}</p>
|
||||
<p className="text-xs text-stone-400 mt-0.5">
|
||||
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPaymentModal(true)}
|
||||
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||
>
|
||||
Pay Now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{([
|
||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
||||
{ id: "packages" as const, label: "Packages", icon: Package },
|
||||
]).map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
tab === id
|
||||
? "bg-(--color-accent-light) text-(--color-accent-dark)"
|
||||
: "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "invoices" && (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
|
||||
<th className="px-5 py-3 font-medium">Date</th>
|
||||
<th className="px-5 py-3 font-medium">Description</th>
|
||||
<th className="px-5 py-3 font-medium">Amount</th>
|
||||
<th className="px-5 py-3 font-medium">Status</th>
|
||||
<th className="px-5 py-3 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((inv) => (
|
||||
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
|
||||
<td className="px-5 py-3 text-stone-700">
|
||||
{new Date(inv.date).toLocaleDateString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
})}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-stone-600">
|
||||
{inv.description || `Invoice ${inv.id.slice(0, 8)}`}
|
||||
</td>
|
||||
<td className="px-5 py-3 font-medium text-stone-800">
|
||||
{formatCents(inv.totalCents)}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
inv.status === "paid"
|
||||
? "bg-green-100 text-green-700"
|
||||
: inv.status === "pending"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: inv.status === "failed"
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{inv.status.charAt(0).toUpperCase() + inv.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<button className="text-stone-400 hover:text-stone-600">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "payment" && (
|
||||
<div className="space-y-4">
|
||||
{paymentMethods.length === 0 ? (
|
||||
<p className="text-gray-500 italic">No payment methods on file</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.map((method) => (
|
||||
<div
|
||||
key={method.id}
|
||||
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-6 bg-gray-200 rounded flex items-center justify-center text-xs">
|
||||
{method.brand.toUpperCase()}
|
||||
</div>
|
||||
<span className="text-stone-700">**** {method.last4}</span>
|
||||
<span className="text-stone-500">
|
||||
{method.expiryMonth}/{method.expiryYear}
|
||||
</span>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const res = await fetch(`/api/portal/payment-methods/${method.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||
});
|
||||
if (res.ok) {
|
||||
setPaymentMethods((prev) => prev.filter((m) => m.id !== method.id));
|
||||
}
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-(--color-accent-light) flex items-center justify-center">
|
||||
<Zap size={18} className="text-(--color-accent)" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-800">Autopay</p>
|
||||
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
<button
|
||||
onClick={() => setAutopay(!autopay)}
|
||||
className={`w-12 h-6 rounded-full transition-colors ${
|
||||
autopay ? "bg-(--color-accent)" : "bg-stone-300"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full shadow transition-transform ${
|
||||
autopay ? "translate-x-6" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-stone-400">
|
||||
{autopay ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "packages" && (
|
||||
<div className="space-y-4">
|
||||
{packages.length === 0 ? (
|
||||
<p className="text-gray-500 italic">No packages purchased</p>
|
||||
) : (
|
||||
packages.map((pkg, index) => (
|
||||
<div key={index} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-stone-800">{pkg.name}</span>
|
||||
<span className="text-stone-600">{pkg.remaining} remaining</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPaymentModal && publishableKey && (
|
||||
<PaymentModalWrapper
|
||||
key={Date.now()}
|
||||
sessionId={sessionId ?? ""}
|
||||
publishableKey={publishableKey}
|
||||
pending={pending}
|
||||
onClose={() => setShowPaymentModal(false)}
|
||||
onSuccess={() => {
|
||||
setInvoices((prev) =>
|
||||
prev.map((inv) =>
|
||||
pending.some((p) => p.id === inv.id) ? { ...inv, status: "paid" as const } : inv
|
||||
)
|
||||
);
|
||||
setShowPaymentModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaymentModalWrapperProps {
|
||||
sessionId: string;
|
||||
publishableKey: string;
|
||||
pending: Invoice[];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function PaymentModalWrapper({ sessionId, publishableKey, pending, onClose, onSuccess }: PaymentModalWrapperProps) {
|
||||
const [stripePromise] = useState(() =>
|
||||
publishableKey ? loadStripe(publishableKey) : Promise.resolve(null)
|
||||
);
|
||||
|
||||
return (
|
||||
<Elements stripe={stripePromise} options={{ mode: "payment", amount: pending.reduce((s, i) => s + i.totalCents, 0), currency: "usd" }}>
|
||||
<PaymentModal sessionId={sessionId} pending={pending} onClose={onClose} onSuccess={onSuccess} />
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaymentModalProps {
|
||||
sessionId: string;
|
||||
pending: Invoice[];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalProps) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set(pending.map((i) => i.id)));
|
||||
const [saveCard, setSaveCard] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const completeModalRef = useRef<HTMLDivElement>(null);
|
||||
const paymentModalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus trap + Escape-to-close for both inline modals
|
||||
useEffect(() => {
|
||||
const modalRef = isComplete ? completeModalRef.current : paymentModalRef.current;
|
||||
if (!modalRef) return;
|
||||
|
||||
const previouslyFocused = document.activeElement as HTMLElement;
|
||||
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const focusableElements = modalRef.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const firstFocusable = focusableElements[0];
|
||||
firstFocusable?.focus();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !modalRef) return;
|
||||
const focusables = modalRef.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [isComplete, onClose]);
|
||||
|
||||
const formatCents = (cents: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
||||
|
||||
const toggleInvoice = (id: string) => {
|
||||
const next = new Set(selectedInvoices);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedInvoices(next);
|
||||
};
|
||||
|
||||
const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0);
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!stripe || !elements) return;
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const isMulti = selectedInvoices.size > 1;
|
||||
const endpoint = isMulti ? "/api/portal/invoices/pay-multiple" : `/api/portal/invoices/${[...selectedInvoices][0]}/pay`;
|
||||
const body = isMulti ? { invoiceIds: [...selectedInvoices] } : {};
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Impersonation-Session-Id": sessionId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error ?? "Failed to initialize payment");
|
||||
}
|
||||
|
||||
const { clientSecret } = await res.json();
|
||||
|
||||
const { error: stripeError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
clientSecret,
|
||||
confirmParams: saveCard
|
||||
? { setup_future_usage: "off_session" }
|
||||
: undefined,
|
||||
redirect: "if_required",
|
||||
});
|
||||
|
||||
if (stripeError) {
|
||||
setError(stripeError.message ?? "Payment failed");
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsComplete(true);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An unexpected error occurred");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isComplete) {
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div ref={completeModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="font-semibold text-stone-800 text-lg mb-2">Payment Successful</h2>
|
||||
<p className="text-stone-500 text-sm mb-6">
|
||||
Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
|
||||
</p>
|
||||
<button onClick={onClose} className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div ref={paymentModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
|
||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-stone-500 mb-4">Select invoices to pay:</p>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{pending.map((inv) => (
|
||||
<label
|
||||
key={inv.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedInvoices.has(inv.id)
|
||||
? "border-(--color-accent) bg-(--color-accent-lighter)"
|
||||
: "border-stone-200 hover:border-stone-300"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedInvoices.has(inv.id)}
|
||||
onChange={() => toggleInvoice(inv.id)}
|
||||
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-800">
|
||||
{inv.description || `Invoice ${inv.id.slice(0, 8)}`}
|
||||
</p>
|
||||
<p className="text-xs text-stone-500">
|
||||
{new Date(inv.date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-stone-800">{formatCents(inv.totalCents)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-stone-200 pt-4 mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-sm text-stone-600">Total</span>
|
||||
<span className="text-lg font-bold text-stone-800">{formatCents(selectedTotal)}</span>
|
||||
</div>
|
||||
|
||||
<PaymentElement />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={saveCard}
|
||||
onChange={(e) => setSaveCard(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
|
||||
/>
|
||||
<span className="text-sm text-stone-600">Save card for future payments</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm font-medium text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePay}
|
||||
disabled={selectedInvoices.size === 0 || isProcessing || !stripe}
|
||||
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? "Processing..." : "Pay Now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BillingPayments(props: BillingPaymentsProps) {
|
||||
return <BillingPaymentsInner {...props} />;
|
||||
}
|
||||
|
||||
export default BillingPayments;
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
sender: "customer" | "business";
|
||||
senderName: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
interface NotificationCategory {
|
||||
email: boolean;
|
||||
sms: boolean;
|
||||
push: boolean;
|
||||
}
|
||||
|
||||
interface NotificationPreferences {
|
||||
appointmentReminders: NotificationCategory;
|
||||
vaccinationAlerts: NotificationCategory;
|
||||
promotional: NotificationCategory;
|
||||
reportCards: NotificationCategory;
|
||||
invoiceReceipts: NotificationCategory;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export function Communication({ readOnly }: Props) {
|
||||
const [tab, setTab] = useState<"messages" | "notifications">("messages");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setTab("messages")}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
tab === "messages" ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
Messages
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("notifications")}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
tab === "notifications" ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<Bell size={14} />
|
||||
Notification Preferences
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "messages" && <MessageThread readOnly={readOnly} />}
|
||||
{tab === "notifications" && <NotificationPreferences readOnly={readOnly} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageThread({ readOnly }: { readOnly: boolean }) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [businessName, setBusinessName] = useState<string>("Business");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBranding() {
|
||||
try {
|
||||
const response = await fetch("/api/branding");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBusinessName(data.businessName || data.name || "Business");
|
||||
}
|
||||
} catch {
|
||||
setBusinessName("Business");
|
||||
}
|
||||
}
|
||||
fetchBranding();
|
||||
}, []);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!newMessage.trim() || readOnly) return;
|
||||
const msg: Message = {
|
||||
id: `m-${Date.now()}`,
|
||||
sender: "customer",
|
||||
senderName: "You",
|
||||
text: newMessage.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
};
|
||||
setMessages([...messages, msg]);
|
||||
setNewMessage("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
|
||||
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
|
||||
<p className="text-sm font-medium text-stone-800">{businessName}</p>
|
||||
<p className="text-xs text-stone-400">Usually replies within a few hours</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-stone-400 text-center text-sm italic">No messages yet</p>
|
||||
) : (
|
||||
messages.map(msg => (
|
||||
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
|
||||
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
|
||||
msg.sender === "customer"
|
||||
? "bg-(--color-accent) text-white rounded-br-md"
|
||||
: "bg-stone-100 text-stone-800 rounded-bl-md"
|
||||
}`}>
|
||||
<p className="text-sm">{msg.text}</p>
|
||||
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
|
||||
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
|
||||
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
||||
</span>
|
||||
{msg.sender === "customer" && (
|
||||
msg.read
|
||||
? <CheckCheck size={12} className="text-white/60" />
|
||||
: <Check size={12} className="text-white/60" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="border-t border-stone-200 p-3 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={e => setNewMessage(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && handleSend()}
|
||||
placeholder="Type a message..."
|
||||
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)/30 focus:border-(--color-accent)"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!newMessage.trim()}
|
||||
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg hover:bg-(--color-accent-hover) disabled:opacity-50"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
||||
const [prefs, setPrefs] = useState<NotificationPreferences>({
|
||||
appointmentReminders: { email: true, sms: true, push: true },
|
||||
vaccinationAlerts: { email: true, sms: false, push: true },
|
||||
promotional: { email: false, sms: false, push: false },
|
||||
reportCards: { email: true, sms: false, push: true },
|
||||
invoiceReceipts: { email: true, sms: false, push: false },
|
||||
});
|
||||
|
||||
type PrefKey = keyof NotificationPreferences;
|
||||
type ChannelKey = "email" | "sms" | "push";
|
||||
|
||||
const toggle = (category: PrefKey, channel: ChannelKey) => {
|
||||
if (readOnly) return;
|
||||
setPrefs(prev => ({
|
||||
...prev,
|
||||
[category]: {
|
||||
...prev[category],
|
||||
[channel]: !prev[category][channel],
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const categories: { key: PrefKey; label: string; desc: string; icon: typeof Bell }[] = [
|
||||
{ key: "appointmentReminders", label: "Appointment Reminders", desc: "Upcoming appointment notifications", icon: Bell },
|
||||
{ key: "vaccinationAlerts", label: "Vaccination Alerts", desc: "Expiration and renewal reminders", icon: FileText },
|
||||
{ key: "promotional", label: "Promotions & Offers", desc: "Deals and seasonal specials", icon: Megaphone },
|
||||
{ key: "reportCards", label: "Report Cards", desc: "Grooming report card delivery", icon: FileText },
|
||||
{ key: "invoiceReceipts", label: "Invoice & Receipts", desc: "Payment confirmations", icon: CreditCard },
|
||||
];
|
||||
|
||||
const channels: { key: ChannelKey; label: string; icon: typeof Mail }[] = [
|
||||
{ key: "email", label: "Email", icon: Mail },
|
||||
{ key: "sms", label: "SMS", icon: Smartphone },
|
||||
{ key: "push", label: "Push", icon: Bell },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-100">
|
||||
<th className="text-left px-5 py-3 text-xs text-stone-400 font-medium">Category</th>
|
||||
{channels.map(ch => (
|
||||
<th key={ch.key} className="px-5 py-3 text-xs text-stone-400 font-medium text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<ch.icon size={12} />
|
||||
{ch.label}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map(cat => (
|
||||
<tr key={cat.key} className="border-b border-stone-50">
|
||||
<td className="px-5 py-3">
|
||||
<p className="font-medium text-stone-800">{cat.label}</p>
|
||||
<p className="text-xs text-stone-400">{cat.desc}</p>
|
||||
</td>
|
||||
{channels.map(ch => (
|
||||
<td key={ch.key} className="px-5 py-3 text-center">
|
||||
<button
|
||||
onClick={() => toggle(cat.key, ch.key)}
|
||||
disabled={readOnly}
|
||||
className={`w-10 h-5 rounded-full transition-colors inline-block ${
|
||||
prefs[cat.key][ch.key] ? "bg-(--color-accent)" : "bg-stone-300"
|
||||
} ${readOnly ? "cursor-not-allowed opacity-60" : ""}`}
|
||||
>
|
||||
<div className={`w-4 h-4 bg-white rounded-full shadow transition-transform ${
|
||||
prefs[cat.key][ch.key] ? "translate-x-5" : "translate-x-0.5"
|
||||
}`} />
|
||||
</button>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Communication;
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Calendar, Clock, PawPrint, CreditCard, Star, ChevronRight, AlertTriangle } from "lucide-react";
|
||||
import { getDevUser } from "../../pages/DevLoginSelector";
|
||||
|
||||
interface DashboardProps {
|
||||
sessionId: string | null;
|
||||
clientName: string;
|
||||
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
|
||||
readOnly: boolean;
|
||||
onReschedule: (appointmentId: string) => void;
|
||||
/** True when a sessionId param was in the URL and the session is still loading */
|
||||
isImpersonating?: boolean;
|
||||
}
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
date: string;
|
||||
time: string;
|
||||
petName: string;
|
||||
serviceName: string;
|
||||
status: string;
|
||||
staffName?: string;
|
||||
services?: string[];
|
||||
addOns?: string[];
|
||||
groomerName?: string;
|
||||
}
|
||||
|
||||
interface Pet {
|
||||
id: string;
|
||||
name: string;
|
||||
species: string;
|
||||
breed?: string;
|
||||
dateOfBirth?: string;
|
||||
weight?: number;
|
||||
healthAlerts: string[];
|
||||
photo?: string;
|
||||
vaccinations?: { name: string; status: string }[];
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
dueDate?: string;
|
||||
items: { description: string; price: number }[];
|
||||
}
|
||||
|
||||
interface Branding {
|
||||
clinicName: string;
|
||||
logoUrl?: string;
|
||||
primaryColor: string;
|
||||
}
|
||||
|
||||
function daysUntil(dateStr: string): number {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
const target = new Date(dateStr);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function Dashboard({
|
||||
sessionId,
|
||||
clientName,
|
||||
onNavigate,
|
||||
readOnly,
|
||||
onReschedule,
|
||||
isImpersonating,
|
||||
}: DashboardProps) {
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [pendingInvoices, setPendingInvoices] = useState<Invoice[]>([]);
|
||||
const [branding, setBranding] = useState<Branding | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!sessionId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
"X-Impersonation-Session-Id": sessionId,
|
||||
};
|
||||
|
||||
const [appointmentsRes, petsRes, invoicesRes, brandingRes] = await Promise.all([
|
||||
fetch("/api/portal/appointments", { headers }),
|
||||
fetch("/api/portal/pets", { headers }),
|
||||
fetch("/api/portal/invoices", { headers }),
|
||||
fetch("/api/branding", { headers }),
|
||||
]);
|
||||
|
||||
if (!appointmentsRes.ok || !petsRes.ok || !invoicesRes.ok || !brandingRes.ok) {
|
||||
throw new Error("Failed to fetch dashboard data");
|
||||
}
|
||||
|
||||
const appointmentsData = await appointmentsRes.json();
|
||||
const petsData = await petsRes.json();
|
||||
const invoicesData = await invoicesRes.json();
|
||||
const brandingData = await brandingRes.json();
|
||||
|
||||
setAppointments(appointmentsData.appointments || []);
|
||||
setPets(petsData.pets || []);
|
||||
|
||||
// Filter for pending invoices only (not "outstanding")
|
||||
const pending = (invoicesData.invoices || []).filter(
|
||||
(invoice: Invoice) => invoice.status === "pending"
|
||||
);
|
||||
setPendingInvoices(pending);
|
||||
|
||||
setBranding(brandingData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [sessionId]);
|
||||
|
||||
const getUpcomingAppointments = (): Appointment[] => {
|
||||
const now = new Date();
|
||||
return appointments
|
||||
.filter((apt) => new Date(`${apt.date}T${apt.time}`) >= now)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(`${a.date}T${a.time}`).getTime() -
|
||||
new Date(`${b.date}T${b.time}`).getTime()
|
||||
)
|
||||
.slice(0, 5);
|
||||
};
|
||||
|
||||
const getPetHealthAlerts = (): { petName: string; alert: string }[] => {
|
||||
return pets
|
||||
.filter((pet) => pet.healthAlerts && pet.healthAlerts.length > 0)
|
||||
.flatMap((pet) =>
|
||||
pet.healthAlerts.map((alert) => ({ petName: pet.name, alert }))
|
||||
);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number): string => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getPendingBalance = (): number => {
|
||||
return pendingInvoices.reduce((sum, invoice) => sum + invoice.amount, 0);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-(--color-accent)" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-2xl p-5">
|
||||
<p className="text-red-700">Error: {error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't redirect to /login if we have a dev user — dev sessions may not have
|
||||
// sessionId set immediately after creation (session?.id may be null due to
|
||||
// timing or API response issues). Dev users are stored in localStorage and
|
||||
// verified via the dev-session flow, so they should see the portal.
|
||||
if (!sessionId && !isImpersonating && !getDevUser()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const upcomingAppointments = getUpcomingAppointments();
|
||||
const healthAlerts = getPetHealthAlerts();
|
||||
const pendingBalance = getPendingBalance();
|
||||
const nextAppt = upcomingAppointments[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-stone-800">
|
||||
Welcome back, {clientName}
|
||||
</h2>
|
||||
<p className="text-stone-500 text-sm mt-1">
|
||||
Here's what's happening at {branding?.clinicName || "your clinic"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Next Appointment */}
|
||||
{nextAppt && (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-(--color-accent-dark)">
|
||||
<Calendar size={16} />
|
||||
Next Appointment
|
||||
</div>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-medium">
|
||||
{nextAppt.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-lg font-semibold text-stone-800">
|
||||
{nextAppt.petName}
|
||||
{nextAppt.groomerName && ` with ${nextAppt.groomerName}`}
|
||||
{nextAppt.staffName && ` with ${nextAppt.staffName}`}
|
||||
</p>
|
||||
<p className="text-stone-600 text-sm mt-1">
|
||||
{nextAppt.services?.join(", ") ||
|
||||
nextAppt.serviceName ||
|
||||
"Appointment"}
|
||||
{nextAppt.addOns && nextAppt.addOns.length > 0 &&
|
||||
` + ${nextAppt.addOns.join(", ")}`}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-stone-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar size={14} />
|
||||
{formatDate(nextAppt.date)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{nextAppt.time}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center sm:text-right">
|
||||
<div className="text-3xl font-bold text-(--color-accent-dark)">
|
||||
{daysUntil(nextAppt.date)}
|
||||
</div>
|
||||
<div className="text-xs text-stone-500">days away</div>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => onReschedule(nextAppt.id)}
|
||||
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Reschedule
|
||||
</button>
|
||||
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
|
||||
Add Notes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pet Cards & Loyalty */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Pet Cards */}
|
||||
{pets.map((pet) => {
|
||||
const petAlerts = pet.healthAlerts || [];
|
||||
return (
|
||||
<button
|
||||
key={pet.id}
|
||||
onClick={() => onNavigate("pets")}
|
||||
className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm text-left hover:border-stone-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-12 h-12 rounded-full bg-(--color-accent-light) flex items-center justify-center text-2xl">
|
||||
{pet.photo || pet.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-800">{pet.name}</p>
|
||||
<p className="text-xs text-stone-500">
|
||||
{pet.breed || pet.species}
|
||||
{pet.weight && ` · ${pet.weight} lbs`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{petAlerts.length > 0 ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded-lg">
|
||||
<AlertTriangle size={12} />
|
||||
{petAlerts.join(", ")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-xs text-green-700 bg-green-50 px-2 py-1 rounded-lg">
|
||||
<PawPrint size={12} />
|
||||
All health records current
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Loyalty Card Placeholder */}
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-(--color-accent-dark) mb-3">
|
||||
<Star size={16} />
|
||||
Loyalty Rewards
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<div className="w-16 h-16 rounded-full bg-(--color-accent-light) flex items-center justify-center mb-3">
|
||||
<Star size={32} className="text-(--color-accent)" />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-stone-800">Coming Soon</p>
|
||||
<p className="text-xs text-stone-500 text-center mt-1">
|
||||
Earn points with every visit and redeem for exclusive rewards
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Balance & Recent Activity */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Pending Invoices */}
|
||||
{pendingInvoices.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-stone-500 mb-1">
|
||||
<CreditCard size={16} />
|
||||
Pending Invoices
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-stone-800">
|
||||
{formatCurrency(pendingBalance)}
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => onNavigate("billing")}
|
||||
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||
>
|
||||
Pay Now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{pendingInvoices.slice(0, 3).map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="text-stone-600">
|
||||
{invoice.invoiceNumber} - {formatCurrency(invoice.amount)}
|
||||
</span>
|
||||
<span className="text-xs text-stone-400">
|
||||
Due {invoice.dueDate ? formatDate(invoice.dueDate) : formatDate(invoice.date)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Health Alerts */}
|
||||
{healthAlerts.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-3">
|
||||
<AlertTriangle size={16} />
|
||||
Health Alerts
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{healthAlerts.slice(0, 5).map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-3 text-sm">
|
||||
<div className="w-2 h-2 rounded-full shrink-0 bg-amber-400" />
|
||||
<span className="text-stone-600 flex-1">
|
||||
<span className="font-medium">{item.petName}:</span>{" "}
|
||||
{item.alert}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate("pets")}
|
||||
className="flex items-center gap-1 text-sm text-(--color-accent-dark) font-medium mt-3 hover:text-(--color-accent)"
|
||||
>
|
||||
View all <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from "react";
|
||||
import { X, Save } from "lucide-react";
|
||||
import type { Pet } from "../mockData.js";
|
||||
|
||||
interface Props {
|
||||
pet?: Pet;
|
||||
onSave: (pet: Pet) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
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 ?? "");
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!pet) return;
|
||||
onSave({ ...pet, name, breed, weight, allergies: notes });
|
||||
}
|
||||
|
||||
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>
|
||||
<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)"
|
||||
/>
|
||||
</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 (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)"
|
||||
/>
|
||||
</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 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"
|
||||
>
|
||||
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)"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2 } 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;
|
||||
}
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: string;
|
||||
confirmationStatus: string | null;
|
||||
customerNotes: string | null;
|
||||
groomerNotes: string | null;
|
||||
reportCardId: string | null;
|
||||
pet: { id: string; name: string; photo: string | null } | null;
|
||||
service: { id: string } | null;
|
||||
staff: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface AppointmentsResponse {
|
||||
appointments: Appointment[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sessionId: string | null;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
function buildHeaders(sessionId: string | null): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionId) {
|
||||
headers["X-Impersonation-Session-Id"] = sessionId;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function PetProfiles({ sessionId, readOnly }: Props) {
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] });
|
||||
const [selectedPetId, setSelectedPetId] = useState<string>("");
|
||||
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
|
||||
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [petsRes, apptsRes] = await Promise.all([
|
||||
fetch("/api/portal/pets", { headers: buildHeaders(sessionId) }),
|
||||
fetch("/api/portal/appointments", { headers: buildHeaders(sessionId) }),
|
||||
]);
|
||||
|
||||
if (!petsRes.ok) {
|
||||
throw new Error("Failed to load pets");
|
||||
}
|
||||
if (!apptsRes.ok) {
|
||||
throw new Error("Failed to load appointments");
|
||||
}
|
||||
|
||||
const petsData = await petsRes.json();
|
||||
const apptsData: AppointmentsResponse = await apptsRes.json();
|
||||
|
||||
setPets(petsData);
|
||||
setAppointments(apptsData);
|
||||
|
||||
if (petsData.length > 0 && !selectedPetId) {
|
||||
setSelectedPetId(petsData[0].id);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [sessionId]);
|
||||
|
||||
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
|
||||
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);
|
||||
}
|
||||
|
||||
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}
|
||||
onCancel={() => setEditingPetId(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-stone-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pets.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-stone-400 text-sm">No pets found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Pet Selector */}
|
||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||
{pets.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => { setSelectedPetId(p.id); setActiveTab("info"); }}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors shrink-0 ${
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Profile Header */}
|
||||
{selectedPet && (
|
||||
<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" />
|
||||
) : (
|
||||
<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-400 text-xs mt-0.5">
|
||||
Born {selectedPet.birthDate ? new Date(selectedPet.birthDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button onClick={() => setEditingPetId(selectedPet.id)} className="p-2 hover:bg-stone-50 rounded-lg">
|
||||
<Edit3 size={16} className="text-stone-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
|
||||
{([
|
||||
{ id: "info", label: "Basic Info", icon: PawPrint },
|
||||
{ id: "medical", label: "Medical", icon: Heart },
|
||||
{ id: "grooming", label: "Grooming", icon: Scissors },
|
||||
{ id: "history", label: "History", icon: Clock },
|
||||
] as const).map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActiveTab(id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap ${
|
||||
activeTab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:text-stone-700"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
{activeTab === "info" && selectedPet && <BasicInfoTab pet={selectedPet} readOnly={readOnly} />}
|
||||
{activeTab === "medical" && selectedPet && <MedicalTab pet={selectedPet} readOnly={readOnly} />}
|
||||
{activeTab === "grooming" && selectedPet && <GroomingTab pet={selectedPet} readOnly={readOnly} />}
|
||||
{activeTab === "history" && <HistoryTab petHistory={petHistory} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
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>
|
||||
<span className="text-sm text-stone-800">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
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"} />
|
||||
{!readOnly && (
|
||||
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
|
||||
Upload Photo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<InfoRow label="Notes" value={pet.notes || "No medical notes on file"} />
|
||||
{!readOnly && (
|
||||
<p className="mt-3 text-xs text-stone-400">
|
||||
Changes to medical notes will be flagged for staff review.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<InfoRow label="Notes" value={pet.notes || "No grooming notes on file"} />
|
||||
{!readOnly && (
|
||||
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
|
||||
Upload Reference Photo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryTab({ petHistory }: { petHistory: Appointment[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{petHistory.length === 0 ? (
|
||||
<p className="text-sm text-stone-400 text-center py-4">No history yet</p>
|
||||
) : (
|
||||
petHistory.map(appt => (
|
||||
<div key={appt.id} className="flex items-center gap-3 py-2 border-b border-stone-50 last:border-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-stone-100 flex items-center justify-center text-xs text-stone-500">
|
||||
<Scissors size={14} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-stone-800">
|
||||
{appt.service ? "Grooming Service" : "Appointment"}
|
||||
</p>
|
||||
<p className="text-xs text-stone-500">
|
||||
with {appt.staff?.name || "Unknown Groomer"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-stone-400">
|
||||
{new Date(appt.startTime).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
</span>
|
||||
{appt.reportCardId && (
|
||||
<span className="text-xs text-(--color-accent-dark) font-medium">Report</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { FileText, Share2, Calendar, Smile, Meh, ChevronRight, Loader2 } from "lucide-react";
|
||||
|
||||
type MoodKey = "calm" | "cooperative" | "anxious" | "wiggly";
|
||||
|
||||
const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: string; bg: string }> = {
|
||||
calm: { icon: Smile, label: "Calm & Relaxed", color: "text-green-700", bg: "bg-green-100" },
|
||||
cooperative: { icon: Smile, label: "Cooperative", color: "text-blue-700", bg: "bg-blue-100" },
|
||||
anxious: { icon: Meh, label: "Anxious", color: "text-amber-700", bg: "bg-amber-100" },
|
||||
wiggly: { icon: Meh, label: "Wiggly", color: "text-purple-700", bg: "bg-purple-100" },
|
||||
};
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
petId: string;
|
||||
serviceId: string;
|
||||
groomerId: string | null;
|
||||
date: string;
|
||||
time: string;
|
||||
status: string;
|
||||
petName?: string;
|
||||
serviceName?: string;
|
||||
groomerName?: string;
|
||||
reportCardId?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export function ReportCards({ sessionId }: Props) {
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedCard, setSelectedCard] = useState<Appointment | null>(null);
|
||||
|
||||
const fetchReportCardsRef = useRef<() => Promise<void>>(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/portal/appointments", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const allAppointments: Appointment[] = data.appointments || data || [];
|
||||
const reportCardAppointments = allAppointments.filter(
|
||||
(appt) => appt.reportCardId
|
||||
);
|
||||
setAppointments(reportCardAppointments);
|
||||
} else {
|
||||
setError("Failed to load report cards.");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load report cards. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void fetchReportCardsRef.current();
|
||||
}, [sessionId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin text-stone-400" size={24} />
|
||||
<span className="ml-3 text-stone-500">Loading report cards...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => { void fetchReportCardsRef.current(); }}
|
||||
className="px-4 py-2 bg-stone-100 text-stone-700 rounded-md hover:bg-stone-200"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (appointments.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-stone-100 flex items-center justify-center">
|
||||
<FileText size={24} className="text-stone-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-stone-800 mb-1">No Report Cards Yet</h3>
|
||||
<p className="text-sm text-stone-500">
|
||||
Report cards from your grooming visits will appear here after your appointments.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedCard) {
|
||||
return <ReportCardDetail card={selectedCard} onBack={() => setSelectedCard(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-stone-500">Grooming report cards from your recent visits</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{appointments.map((card) => {
|
||||
const moodKey: MoodKey = "cooperative";
|
||||
const mood = MOOD_CONFIG[moodKey];
|
||||
const MoodIcon = mood.icon;
|
||||
return (
|
||||
<button
|
||||
key={card.id}
|
||||
onClick={() => setSelectedCard(card)}
|
||||
className="w-full bg-white rounded-2xl border border-stone-200 p-5 shadow-sm text-left hover:border-stone-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-(--color-accent-light) flex items-center justify-center text-(--color-accent)">
|
||||
<FileText size={24} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-stone-800">{card.petName || "Pet"}'s Report Card</h3>
|
||||
<ChevronRight size={16} className="text-stone-400" />
|
||||
</div>
|
||||
<p className="text-sm text-stone-500 mt-0.5">
|
||||
{card.serviceName || "Grooming"} with {card.groomerName || "your groomer"}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<span className="flex items-center gap-1 text-xs text-stone-400">
|
||||
<Calendar size={12} />
|
||||
{new Date(card.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${mood.bg} ${mood.color}`}>
|
||||
<MoodIcon size={12} />
|
||||
{mood.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportCardDetail({ card, onBack }: { card: Appointment; onBack: () => void }) {
|
||||
const moodKey: MoodKey = "cooperative";
|
||||
const mood = MOOD_CONFIG[moodKey];
|
||||
const MoodIcon = mood.icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-(--color-accent-dark) font-medium hover:underline"
|
||||
>
|
||||
Back to Report Cards
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-(--color-accent-lighter) to-(--color-accent-light) p-6">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="text-xl font-semibold text-stone-800">
|
||||
{card.petName || "Pet"}'s Grooming Report
|
||||
</h2>
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-white/80 text-stone-700 rounded-lg text-sm font-medium hover:bg-white">
|
||||
<Share2 size={14} />
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-stone-600">
|
||||
{new Date(card.date).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
{card.groomerName ? ` · Groomer: ${card.groomerName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Before & After */}
|
||||
<div>
|
||||
<h3 className="font-medium text-stone-800 mb-3">Before & After</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="rounded-xl bg-stone-50 p-4">
|
||||
<p className="text-xs font-medium text-stone-400 uppercase mb-2">Before</p>
|
||||
<div className="w-full h-32 bg-stone-200 rounded-lg flex items-center justify-center text-stone-400 text-sm mb-2">
|
||||
Photo placeholder
|
||||
</div>
|
||||
<p className="text-sm text-stone-600">Before photo description not available.</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-(--color-accent-lighter) p-4">
|
||||
<p className="text-xs font-medium text-(--color-accent) uppercase mb-2">After</p>
|
||||
<div className="w-full h-32 bg-(--color-accent-light) rounded-lg flex items-center justify-center text-(--color-accent) text-sm mb-2">
|
||||
Photo placeholder
|
||||
</div>
|
||||
<p className="text-sm text-stone-700">After photo description not available.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div>
|
||||
<h3 className="font-medium text-stone-800 mb-2">Services Performed</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-3 py-1 bg-stone-100 rounded-full text-sm text-stone-700">
|
||||
{card.serviceName || "Grooming"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Behavior */}
|
||||
<div>
|
||||
<h3 className="font-medium text-stone-800 mb-2">Behavior & Mood</h3>
|
||||
<div className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl ${mood.bg}`}>
|
||||
<MoodIcon size={20} className={mood.color} />
|
||||
<span className={`font-medium ${mood.color}`}>{mood.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Groomer's Note */}
|
||||
<div className="bg-(--color-accent-lighter) rounded-xl p-4">
|
||||
<h3 className="font-medium text-stone-800 mb-2">
|
||||
A Note from {card.groomerName || "Your Groomer"}
|
||||
</h3>
|
||||
<p className="text-sm text-stone-700 italic leading-relaxed">
|
||||
"Report card details are not yet available. Please check back after your visit."
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Next Appointment CTA */}
|
||||
<div className="bg-white border border-stone-200 rounded-xl p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-800">Book your next visit</p>
|
||||
<p className="text-xs text-stone-500">Schedule your next grooming appointment</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Pre-select the service from report card (serviceId/serviceName) once BookPage supports service pre-selection via URL param
|
||||
const params = new URLSearchParams();
|
||||
if (card.petName) params.set("petName", card.petName);
|
||||
if (card.serviceName) params.set("serviceName", card.serviceName);
|
||||
window.location.href = `/admin/book${params.size > 0 ? `?${params.toString()}` : ""}`;
|
||||
}}
|
||||
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||
>
|
||||
Rebook Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
// React 19 uses the development build for `act` — required for testing-library compatibility
|
||||
process.env.NODE_ENV = "test";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user