fix(e2e): block service workers to prevent route mock bypass #66
@@ -22,6 +22,7 @@ export default defineConfig({
|
||||
baseURL: "http://localhost:8080",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
serviceWorkers: "block",
|
||||
},
|
||||
|
||||
projects: [
|
||||
|
||||
@@ -48,38 +48,38 @@ test("customer portal loads at root", async ({ page }) => {
|
||||
|
||||
test("admin appointments page loads", async ({ page }) => {
|
||||
await page.goto("/admin");
|
||||
await expect(page.getByText("Groom Book")).toBeVisible();
|
||||
await expect(page.getByText("GroomBook")).toBeVisible();
|
||||
// Calendar/appointments view renders
|
||||
await expect(page.locator("nav")).toBeVisible();
|
||||
});
|
||||
|
||||
test("admin clients page loads", async ({ page }) => {
|
||||
await page.goto("/admin/clients");
|
||||
await expect(page.getByText("Groom Book")).toBeVisible();
|
||||
await expect(page.getByText("GroomBook")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Clients" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("admin services page loads", async ({ page }) => {
|
||||
await page.goto("/admin/services");
|
||||
await expect(page.getByText("Groom Book")).toBeVisible();
|
||||
await expect(page.getByText("GroomBook")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Services" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("admin staff page loads", async ({ page }) => {
|
||||
await page.goto("/admin/staff");
|
||||
await expect(page.getByText("Groom Book")).toBeVisible();
|
||||
await expect(page.getByText("GroomBook")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Staff" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("admin invoices page loads", async ({ page }) => {
|
||||
await page.goto("/admin/invoices");
|
||||
await expect(page.getByText("Groom Book")).toBeVisible();
|
||||
await expect(page.getByText("GroomBook")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("admin reports page loads", async ({ page }) => {
|
||||
await page.goto("/admin/reports");
|
||||
await expect(page.getByText("Groom Book")).toBeVisible();
|
||||
await expect(page.getByText("GroomBook")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Reports" })).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
+27
-14
@@ -23,29 +23,42 @@ const NAV_LINKS = [
|
||||
function AdminLayout() {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif" }}>
|
||||
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif", background: "#f0f2f5" }}>
|
||||
<nav
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<strong style={{ marginRight: "1rem", fontSize: 16 }}>Groom Book</strong>
|
||||
<strong style={{
|
||||
marginRight: "1.25rem",
|
||||
fontSize: 17,
|
||||
color: "#1a202c",
|
||||
letterSpacing: "-0.02em",
|
||||
}}>
|
||||
<span style={{ color: "#4f8a6f" }}>Groom</span>Book
|
||||
</strong>
|
||||
<Link
|
||||
to="/admin/book"
|
||||
style={{
|
||||
padding: "0.35rem 0.75rem",
|
||||
borderRadius: 4,
|
||||
padding: "0.4rem 0.85rem",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
background: "#4f8a6f",
|
||||
background: "linear-gradient(135deg, #4f8a6f, #3d7a5f)",
|
||||
marginRight: "0.5rem",
|
||||
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
||||
}}
|
||||
>
|
||||
Book
|
||||
@@ -60,13 +73,13 @@ function AdminLayout() {
|
||||
key={to}
|
||||
to={to}
|
||||
style={{
|
||||
padding: "0.35rem 0.75rem",
|
||||
borderRadius: 4,
|
||||
padding: "0.4rem 0.75rem",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
fontWeight: active ? 600 : 400,
|
||||
color: active ? "#1d4ed8" : "#374151",
|
||||
background: active ? "#eff6ff" : "transparent",
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 600 : 500,
|
||||
color: active ? "#2d6a4f" : "#4b5563",
|
||||
background: active ? "#ecfdf5" : "transparent",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
@@ -74,7 +87,7 @@ function AdminLayout() {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<main style={{ padding: "1rem 1.5rem" }}>
|
||||
<main style={{ padding: "1.25rem 1.5rem" }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppointmentsPage />} />
|
||||
<Route path="/clients" element={<ClientsPage />} />
|
||||
|
||||
@@ -23,12 +23,14 @@ function renderApp(route = "/admin") {
|
||||
describe("App navigation", () => {
|
||||
it("renders the Groom Book brand", () => {
|
||||
const nav = renderApp();
|
||||
expect(within(nav).getByText("Groom Book")).toBeInTheDocument();
|
||||
expect(
|
||||
within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Book CTA button", () => {
|
||||
const nav = renderApp();
|
||||
expect(within(nav).getByText("Book")).toBeInTheDocument();
|
||||
expect(within(nav).getByRole("link", { name: "Book" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all primary nav links", () => {
|
||||
@@ -61,6 +63,8 @@ describe("App navigation", () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
// Customer portal should render at root - no admin nav present
|
||||
expect(screen.queryByText("Groom Book")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+48
-2
@@ -10,11 +10,13 @@ body {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: #1a202c;
|
||||
background: #f7fafc;
|
||||
background: #f0f2f5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #4f8a6f;
|
||||
color: #3d7a5f;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -25,4 +27,48 @@ a:hover {
|
||||
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: #4f8a6f;
|
||||
box-shadow: 0 0 0 3px rgba(79, 138, 111, 0.12);
|
||||
}
|
||||
|
||||
/* ─── 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;
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ export function AppointmentsPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openNewForm()}
|
||||
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", marginLeft: "auto", borderColor: "#3b82f6" }}
|
||||
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", marginLeft: "auto", borderColor: "#4f8a6f" }}
|
||||
>
|
||||
+ New Appointment
|
||||
</button>
|
||||
@@ -370,11 +370,11 @@ export function AppointmentsPage() {
|
||||
{days.map((day, i) => {
|
||||
const isToday = formatDate(day) === formatDate(new Date());
|
||||
return (
|
||||
<div key={i} style={{ border: "1px solid #e2e8f0", borderRadius: 6, overflow: "hidden", minHeight: 180 }}>
|
||||
<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.35rem 0.5rem",
|
||||
background: isToday ? "#3b82f6" : "#f8fafc",
|
||||
padding: "0.4rem 0.6rem",
|
||||
background: isToday ? "linear-gradient(135deg, #4f8a6f, #3d7a5f)" : "#f8fafc",
|
||||
color: isToday ? "#fff" : "#374151",
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
@@ -594,7 +594,7 @@ export function AppointmentsPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
|
||||
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
|
||||
>
|
||||
{saving
|
||||
? "Saving…"
|
||||
@@ -841,19 +841,20 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.35rem 0.75rem",
|
||||
padding: "0.4rem 0.85rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 4,
|
||||
background: "#f9fafb",
|
||||
borderRadius: 6,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "0.4rem 0.5rem",
|
||||
padding: "0.45rem 0.6rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 4,
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
@@ -315,7 +315,7 @@ export function ClientsPage() {
|
||||
<h1 style={{ margin: 0, fontSize: 20 }}>Clients</h1>
|
||||
<button
|
||||
onClick={openNewClient}
|
||||
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6", marginLeft: "auto", padding: "0.25rem 0.6rem" }}
|
||||
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto", padding: "0.3rem 0.7rem" }}
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
@@ -387,7 +387,7 @@ export function ClientsPage() {
|
||||
) : (
|
||||
<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 #e2e8f0", borderRadius: 8, padding: "0.75rem" }}>
|
||||
<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)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<strong style={{ fontSize: 15 }}>{p.name}</strong>
|
||||
<div style={{ display: "flex", gap: "0.3rem" }}>
|
||||
@@ -498,7 +498,7 @@ export function ClientsPage() {
|
||||
</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: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}>
|
||||
<button type="submit" disabled={savingClient} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
|
||||
{savingClient ? "Saving…" : editingClient ? "Save Changes" : "Create Client"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowClientForm(false)} style={btnStyle}>Cancel</button>
|
||||
@@ -637,7 +637,7 @@ export function ClientsPage() {
|
||||
</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: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}>
|
||||
<button type="submit" disabled={savingLog} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
|
||||
{savingLog ? "Saving…" : "Save Visit Log"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowLogForm(false)} style={btnStyle}>Cancel</button>
|
||||
@@ -674,9 +674,9 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13,
|
||||
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.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 4, fontSize: 14, boxSizing: "border-box",
|
||||
width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, boxSizing: "border-box",
|
||||
};
|
||||
|
||||
@@ -287,7 +287,7 @@ function NewGroupBookingForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
|
||||
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
|
||||
>
|
||||
{saving ? "Booking…" : "Create Group Booking"}
|
||||
</button>
|
||||
@@ -471,7 +471,7 @@ export function GroupBookingPage() {
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
style={{ ...btnStyle, marginLeft: "auto", backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
|
||||
style={{ ...btnStyle, marginLeft: "auto", backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
|
||||
>
|
||||
+ New Group Booking
|
||||
</button>
|
||||
@@ -558,25 +558,26 @@ function Field({
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.35rem 0.75rem",
|
||||
padding: "0.4rem 0.85rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 4,
|
||||
background: "#f9fafb",
|
||||
borderRadius: 6,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "0.4rem 0.5rem",
|
||||
padding: "0.45rem 0.6rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 4,
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: "0.45rem 1rem",
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
padding: "0.5rem 1rem",
|
||||
borderBottom: "1px solid #f3f4f6",
|
||||
color: "#374151",
|
||||
};
|
||||
|
||||
@@ -129,7 +129,7 @@ function CreateFromAppointmentForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !selectedApptId}
|
||||
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
|
||||
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
|
||||
>
|
||||
{saving ? "Creating…" : "Create Invoice"}
|
||||
</button>
|
||||
@@ -540,7 +540,7 @@ export function InvoicesPage() {
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6", marginLeft: "auto" }}
|
||||
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto" }}
|
||||
>
|
||||
+ Create Invoice
|
||||
</button>
|
||||
@@ -551,11 +551,12 @@ export function InvoicesPage() {
|
||||
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.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" }}>
|
||||
<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>
|
||||
))}
|
||||
@@ -582,6 +583,7 @@ export function InvoicesPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
@@ -647,15 +649,15 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.35rem 0.75rem", border: "1px solid #d1d5db",
|
||||
borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13,
|
||||
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.4rem 0.5rem", border: "1px solid #d1d5db",
|
||||
borderRadius: 4, fontSize: 14, boxSizing: "border-box",
|
||||
width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db",
|
||||
borderRadius: 6, fontSize: 14, boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0",
|
||||
padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6",
|
||||
};
|
||||
|
||||
@@ -84,14 +84,15 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: 8,
|
||||
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: 12, color: "#6b7280", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
<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>
|
||||
@@ -102,7 +103,7 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h2 style={{ fontSize: 16, fontWeight: 700, margin: "1.5rem 0 0.75rem", color: "#111827", borderBottom: "1px solid #e2e8f0", paddingBottom: "0.4rem" }}>
|
||||
<h2 style={{ fontSize: 15, fontWeight: 700, margin: "1.75rem 0 0.75rem", color: "#1a202c", borderBottom: "2px solid #e5e7eb", paddingBottom: "0.5rem" }}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
@@ -110,35 +111,37 @@ function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function Table({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) {
|
||||
return (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "#f8fafc" }}>
|
||||
{headers.map((h) => (
|
||||
<th key={h} style={{ textAlign: "left", padding: "0.4rem 0.75rem", borderBottom: "1px solid #e2e8f0", fontWeight: 600, color: "#374151" }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} style={{ borderBottom: "1px solid #f1f5f9" }}>
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} style={{ padding: "0.4rem 0.75rem", color: "#374151" }}>
|
||||
{cell}
|
||||
</td>
|
||||
<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>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={headers.length} style={{ padding: "1rem 0.75rem", color: "#9ca3af" }}>
|
||||
No data for this period.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,7 +270,7 @@ export function ReportsPage() {
|
||||
<option value="month">Month</option>
|
||||
</select>
|
||||
</label>
|
||||
<button onClick={loadAll} style={{ ...btnStyle, background: "#1d4ed8", color: "#fff", borderColor: "#1d4ed8" }}>
|
||||
<button onClick={loadAll} style={{ ...btnStyle, background: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
|
||||
{loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: "0.5rem" }}>
|
||||
@@ -390,19 +393,19 @@ export function ReportsPage() {
|
||||
// ─── Shared styles ────────────────────────────────────────────────────────────
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.35rem 0.75rem",
|
||||
padding: "0.4rem 0.85rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 4,
|
||||
background: "#f9fafb",
|
||||
borderRadius: 6,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: "0.3rem 0.4rem",
|
||||
padding: "0.35rem 0.5rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 4,
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
marginLeft: "0.25rem",
|
||||
};
|
||||
|
||||
@@ -119,7 +119,7 @@ export function ServicesPage() {
|
||||
<h1 style={{ margin: 0 }}>Services</h1>
|
||||
<button
|
||||
onClick={openNew}
|
||||
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6", marginLeft: "auto" }}
|
||||
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto" }}
|
||||
>
|
||||
+ Add Service
|
||||
</button>
|
||||
@@ -128,11 +128,12 @@ export function ServicesPage() {
|
||||
{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.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" }}>
|
||||
<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>
|
||||
))}
|
||||
@@ -171,6 +172,7 @@ export function ServicesPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
@@ -230,7 +232,7 @@ export function ServicesPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
|
||||
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
|
||||
>
|
||||
{saving ? "Saving…" : editing ? "Save Changes" : "Create Service"}
|
||||
</button>
|
||||
@@ -277,15 +279,15 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.35rem 0.75rem", border: "1px solid #d1d5db",
|
||||
borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13,
|
||||
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.4rem 0.5rem", border: "1px solid #d1d5db",
|
||||
borderRadius: 4, fontSize: 14, boxSizing: "border-box",
|
||||
width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db",
|
||||
borderRadius: 6, fontSize: 14, boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0",
|
||||
padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6",
|
||||
};
|
||||
|
||||
@@ -78,7 +78,7 @@ export function StaffPage() {
|
||||
<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: "#3b82f6", color: "#fff", borderColor: "#3b82f6", marginLeft: "auto" }}>
|
||||
<button onClick={openNew} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto" }}>
|
||||
+ Add Staff
|
||||
</button>
|
||||
</div>
|
||||
@@ -86,11 +86,12 @@ export function StaffPage() {
|
||||
{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", "Status", ""].map((h) => (
|
||||
<th key={h} style={{ textAlign: "left", padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" }}>{h}</th>
|
||||
<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>
|
||||
@@ -113,6 +114,7 @@ export function StaffPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
@@ -143,7 +145,7 @@ export function StaffPage() {
|
||||
</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: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}>
|
||||
<button type="submit" disabled={saving} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
|
||||
{saving ? "Saving…" : editing ? "Save Changes" : "Add Staff"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>Cancel</button>
|
||||
@@ -156,7 +158,7 @@ export function StaffPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = { padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13 };
|
||||
const inputStyle: React.CSSProperties = { width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 4, fontSize: 14, boxSizing: "border-box" };
|
||||
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.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" };
|
||||
const tdStyle: React.CSSProperties = { padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6" };
|
||||
|
||||
Reference in New Issue
Block a user