Task 4ae0c879: Presence override with live consequence count (#207)
This commit was merged in pull request #207.
This commit is contained in:
@@ -80,3 +80,47 @@ export async function fetchPresenceOverrides(
|
|||||||
);
|
);
|
||||||
return body.overrides;
|
return body.overrides;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PresenceOverrideRange {
|
||||||
|
child_id: number;
|
||||||
|
/** "YYYY-MM-DD", inclusive. */
|
||||||
|
start_date: string;
|
||||||
|
/** "YYYY-MM-DD", inclusive; never before start_date. */
|
||||||
|
end_date: string;
|
||||||
|
is_present: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceOverrideBody extends PresenceOverrideRange {
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`;
|
||||||
|
|
||||||
|
function postJson(path: string, body: unknown): Promise<Response> {
|
||||||
|
return apiFetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many open upcoming tasks saving this override would remove. Computed
|
||||||
|
* by the API before anything is saved; completed instances never count.
|
||||||
|
*/
|
||||||
|
export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise<number> {
|
||||||
|
const body = await readJson<{ removed: number }>(
|
||||||
|
await postJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, range),
|
||||||
|
);
|
||||||
|
return body.removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPresenceOverride(body: PresenceOverrideBody): Promise<PresenceOverride> {
|
||||||
|
return readJson<PresenceOverride>(await postJson(PRESENCE_OVERRIDES_PATH, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePresenceOverride(overrideId: number): Promise<void> {
|
||||||
|
await readJson<{ status: string }>(
|
||||||
|
await apiFetch(`${PRESENCE_OVERRIDES_PATH}/${overrideId}`, { method: "DELETE" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
--nq-a-danger-strong: #b91c1c;
|
--nq-a-danger-strong: #b91c1c;
|
||||||
--nq-a-danger-bg: #fee2e2;
|
--nq-a-danger-bg: #fee2e2;
|
||||||
--nq-a-warning-ink: #92400e;
|
--nq-a-warning-ink: #92400e;
|
||||||
|
--nq-a-warning-ink-2: #78350f;
|
||||||
--nq-a-warning-bg: #fef3c7;
|
--nq-a-warning-bg: #fef3c7;
|
||||||
--nq-a-warning-border: rgba(217, 119, 6, 0.35);
|
--nq-a-warning-border: rgba(217, 119, 6, 0.35);
|
||||||
--nq-a-info-bg: #dbeafe;
|
--nq-a-info-bg: #dbeafe;
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/* Presence override editor — roomy density pushed screen
|
||||||
|
(design/ADMIN-SPEC.md §6). Covers the tab bar like a pushed route. */
|
||||||
|
|
||||||
|
.override-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 30;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--nq-a-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
border-bottom: 1px solid var(--nq-a-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-back {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: var(--nq-a-tap-min);
|
||||||
|
height: var(--nq-a-tap-min);
|
||||||
|
margin: -6px 0 -6px -12px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--nq-a-ink);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--nq-a-size-screen);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-sub {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form card: roomy density, 18px padding, 16px gaps. */
|
||||||
|
.override-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-label {
|
||||||
|
font-size: var(--nq-a-size-label);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--nq-a-track-label);
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-segments {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-segments--3 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-segments--2 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-segment {
|
||||||
|
min-height: var(--nq-a-tap-min);
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-segment--gradient[aria-pressed="true"] {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--nq-brand-gradient);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-segment--ink[aria-pressed="true"] {
|
||||||
|
border-color: #18181d;
|
||||||
|
background: #18181d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-dates {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--nq-a-tap-min);
|
||||||
|
padding: 13px;
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
color: var(--nq-a-ink);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-date {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Consequence warning */
|
||||||
|
.override-warning {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--nq-a-warning-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--nq-a-warning-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-warning-glyph {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
color: var(--nq-a-warning-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-warning-headline {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nq-a-warning-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-warning-body {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nq-a-warning-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--nq-a-density-radius);
|
||||||
|
background: var(--nq-a-danger-bg);
|
||||||
|
color: var(--nq-a-danger-strong);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer: Cancel (flex 1) + Save override (flex 2), 15px vertical padding. */
|
||||||
|
.override-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-cancel,
|
||||||
|
.override-save {
|
||||||
|
padding: 15px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-cancel {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
color: var(--nq-a-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-save {
|
||||||
|
flex: 2;
|
||||||
|
border: 0;
|
||||||
|
background: var(--nq-brand-gradient);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-cancel:disabled,
|
||||||
|
.override-save:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import App from "../App";
|
||||||
|
import ScheduleTab from "./ScheduleTab";
|
||||||
|
import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
|
||||||
|
import type { AdminChild } from "../api/definitions";
|
||||||
|
import type { PresenceOverride, PresenceSchedule } from "../api/presence";
|
||||||
|
import { storeTokens } from "../auth/oidc";
|
||||||
|
|
||||||
|
function child(id: number, display_name: string, sort_order: number): AdminChild {
|
||||||
|
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHILDREN = [child(1, "Declan", 1), child(2, "Maeve", 2), child(3, "Rory", 3)];
|
||||||
|
|
||||||
|
const DECLAN: PresenceSchedule = {
|
||||||
|
child_id: 1,
|
||||||
|
cycle_length_weeks: 2,
|
||||||
|
anchor_date: "2026-09-07",
|
||||||
|
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_OVERRIDES: PresenceOverride[] = [
|
||||||
|
{ id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TODAY = "2026-09-23";
|
||||||
|
const OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
|
||||||
|
const CONSEQUENCE_PATH = `${OVERRIDES_PATH}/consequence`;
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Call {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
body: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
let overrides: PresenceOverride[];
|
||||||
|
let consequence: (body: Record<string, unknown>) => Response | Promise<Response>;
|
||||||
|
let createResponse: (body: Record<string, unknown>) => Response;
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function calls(): Call[] {
|
||||||
|
return fetchMock.mock.calls.map(([url, init]: [string, RequestInit | undefined]) => ({
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
path: new URL(String(url), "http://x").pathname,
|
||||||
|
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function consequenceBodies(): unknown[] {
|
||||||
|
return calls()
|
||||||
|
.filter((c) => c.path === CONSEQUENCE_PATH)
|
||||||
|
.map((c) => c.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function routeFetch(url: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const path = new URL(url, "http://x").pathname;
|
||||||
|
const method = init.method ?? "GET";
|
||||||
|
const body = init.body ? JSON.parse(String(init.body)) : undefined;
|
||||||
|
if (path === CONSEQUENCE_PATH && method === "POST") return consequence(body);
|
||||||
|
if (path === OVERRIDES_PATH && method === "POST") return createResponse(body);
|
||||||
|
const deleteMatch = /\/presence-overrides\/(\d+)$/.exec(path);
|
||||||
|
if (deleteMatch && method === "DELETE") {
|
||||||
|
overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
|
||||||
|
return jsonResponse({ status: "ok" });
|
||||||
|
}
|
||||||
|
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
|
||||||
|
const scheduleMatch = /\/children\/(\d+)\/presence-schedule$/.exec(path);
|
||||||
|
if (scheduleMatch) {
|
||||||
|
return jsonResponse({ schedule: Number(scheduleMatch[1]) === 1 ? DECLAN : null });
|
||||||
|
}
|
||||||
|
if (path === OVERRIDES_PATH) return jsonResponse({ overrides });
|
||||||
|
return jsonResponse({ detail: "unexpected" }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: (response: Response) => void;
|
||||||
|
const promise = new Promise<Response>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pastDebounce = () => new Promise((r) => setTimeout(r, CONSEQUENCE_DEBOUNCE_MS + 150));
|
||||||
|
|
||||||
|
function headline(): string {
|
||||||
|
return screen.getByTestId("override-consequence-headline").textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function warningBody(): string {
|
||||||
|
return screen.getByTestId("override-consequence").querySelector(".override-warning-body")?.textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveButton(): HTMLButtonElement {
|
||||||
|
return screen.getByRole("button", { name: "Save override" }) as HTMLButtonElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDate(label: "From" | "Through", value: string) {
|
||||||
|
fireEvent.change(screen.getByLabelText(label), { target: { value } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditor() {
|
||||||
|
render(<ScheduleTab today={TODAY} />);
|
||||||
|
await screen.findByTestId("pattern-1");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||||
|
await screen.findByRole("heading", { name: "Presence override" });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OverrideEditor", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||||
|
overrides = [...INITIAL_OVERRIDES];
|
||||||
|
consequence = () => jsonResponse({ removed: 4 });
|
||||||
|
createResponse = (body) => {
|
||||||
|
const created: PresenceOverride = {
|
||||||
|
id: 50,
|
||||||
|
child_id: body.child_id as number,
|
||||||
|
start_date: body.start_date as string,
|
||||||
|
end_date: body.end_date as string,
|
||||||
|
is_present: body.is_present as boolean,
|
||||||
|
note: (body.note as string | undefined) ?? null,
|
||||||
|
};
|
||||||
|
overrides = [...overrides, created];
|
||||||
|
return jsonResponse(created, 201);
|
||||||
|
};
|
||||||
|
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the pushed screen per ADMIN-SPEC §6", async () => {
|
||||||
|
await openEditor();
|
||||||
|
expect(screen.getByText("Beats the custody pattern for these dates")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Back" })).toBeTruthy();
|
||||||
|
const kids = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
|
||||||
|
expect(kids.map((b) => [b.textContent, b.getAttribute("aria-pressed")])).toEqual([
|
||||||
|
["Declan", "true"],
|
||||||
|
["Maeve", "false"],
|
||||||
|
["Rory", "false"],
|
||||||
|
]);
|
||||||
|
const status = within(screen.getByRole("group", { name: "Status for these dates" }));
|
||||||
|
expect(status.getByRole("button", { name: "Away" }).getAttribute("aria-pressed")).toBe("true");
|
||||||
|
expect(status.getByRole("button", { name: "Home" }).getAttribute("aria-pressed")).toBe("false");
|
||||||
|
expect((screen.getByLabelText("From") as HTMLInputElement).value).toBe(TODAY);
|
||||||
|
expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe(TODAY);
|
||||||
|
expect(screen.getByLabelText("Reason (optional)")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the live consequence count from the API and recounts on every change", async () => {
|
||||||
|
await openEditor();
|
||||||
|
// Save is off until the count is on screen: the warning precedes saving.
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
await waitFor(() => expect(headline()).toBe("This removes 4 upcoming tasks"));
|
||||||
|
expect(consequenceBodies()).toEqual([
|
||||||
|
{ child_id: 1, start_date: TODAY, end_date: TODAY, is_present: false },
|
||||||
|
]);
|
||||||
|
expect(warningBody()).toBe(
|
||||||
|
"Declan's open tasks Sep 23 are removed while away. Completed days are untouched.",
|
||||||
|
);
|
||||||
|
expect(saveButton().disabled).toBe(false);
|
||||||
|
|
||||||
|
consequence = () => jsonResponse({ removed: 9 });
|
||||||
|
setDate("Through", "2026-09-27");
|
||||||
|
await waitFor(() => expect(headline()).toBe("This removes 9 upcoming tasks"));
|
||||||
|
expect(consequenceBodies()[1]).toEqual({
|
||||||
|
child_id: 1,
|
||||||
|
start_date: TODAY,
|
||||||
|
end_date: "2026-09-27",
|
||||||
|
is_present: false,
|
||||||
|
});
|
||||||
|
expect(warningBody()).toBe(
|
||||||
|
"Declan's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
|
||||||
|
);
|
||||||
|
|
||||||
|
consequence = () => jsonResponse({ removed: 2 });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
||||||
|
await waitFor(() => expect(headline()).toBe("This removes 2 upcoming tasks"));
|
||||||
|
expect(consequenceBodies()[2]).toEqual({
|
||||||
|
child_id: 2,
|
||||||
|
start_date: TODAY,
|
||||||
|
end_date: "2026-09-27",
|
||||||
|
is_present: false,
|
||||||
|
});
|
||||||
|
expect(warningBody()).toBe(
|
||||||
|
"Maeve's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
|
||||||
|
);
|
||||||
|
|
||||||
|
// A Home override removes nothing by definition: no request, count 0.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Home" }));
|
||||||
|
expect(headline()).toBe("This removes 0 upcoming tasks");
|
||||||
|
expect(warningBody()).toBe(
|
||||||
|
"Maeve is home Sep 23 – 27, so no tasks are removed. Completed days are untouched.",
|
||||||
|
);
|
||||||
|
await pastDebounce();
|
||||||
|
expect(consequenceBodies()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the API's count verbatim — completed instances are excluded server-side", async () => {
|
||||||
|
// The range holds 3 open instances and 1 already completed. The API
|
||||||
|
// excludes the completed one (D-005; proven by
|
||||||
|
// tests/test_api_admin_presence_consequence.py::test_completed_instance_in_range_is_not_counted
|
||||||
|
// and ::test_count_matches_a_direct_materialise_comparison), so it answers 3,
|
||||||
|
// and the editor shows exactly that — it never counts days on its own.
|
||||||
|
consequence = () => jsonResponse({ removed: 3 });
|
||||||
|
await openEditor();
|
||||||
|
setDate("Through", "2026-09-25");
|
||||||
|
await waitFor(() => expect(headline()).toBe("This removes 3 upcoming tasks"));
|
||||||
|
expect(warningBody()).toBe(
|
||||||
|
"Declan's open tasks Sep 23 – 25 are removed while away. Completed days are untouched.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stale consequence response never overwrites a newer one", async () => {
|
||||||
|
const first = deferred();
|
||||||
|
const second = deferred();
|
||||||
|
const pending = [first, second];
|
||||||
|
consequence = () => pending.shift()!.promise;
|
||||||
|
await openEditor();
|
||||||
|
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
|
||||||
|
|
||||||
|
setDate("Through", "2026-09-26");
|
||||||
|
await waitFor(() => expect(consequenceBodies()).toHaveLength(2));
|
||||||
|
|
||||||
|
second.resolve(jsonResponse({ removed: 5 }));
|
||||||
|
await waitFor(() => expect(headline()).toBe("This removes 5 upcoming tasks"));
|
||||||
|
first.resolve(jsonResponse({ removed: 99 }));
|
||||||
|
await pastDebounce();
|
||||||
|
expect(headline()).toBe("This removes 5 upcoming tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("debounces rapid edits into one request for the final range", async () => {
|
||||||
|
await openEditor();
|
||||||
|
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
|
||||||
|
setDate("Through", "2026-09-24");
|
||||||
|
setDate("Through", "2026-09-25");
|
||||||
|
setDate("Through", "2026-09-26");
|
||||||
|
await pastDebounce();
|
||||||
|
expect(consequenceBodies().slice(1)).toEqual([
|
||||||
|
{ child_id: 1, start_date: TODAY, end_date: "2026-09-26", is_present: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an incomplete or reversed range sends no consequence request and cannot be saved", async () => {
|
||||||
|
await openEditor();
|
||||||
|
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
|
||||||
|
|
||||||
|
setDate("Through", "");
|
||||||
|
expect(headline()).toBe("Choose a valid date range");
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
await pastDebounce();
|
||||||
|
expect(consequenceBodies()).toHaveLength(1);
|
||||||
|
|
||||||
|
setDate("Through", "2026-09-20"); // before From
|
||||||
|
expect(headline()).toBe("Choose a valid date range");
|
||||||
|
await pastDebounce();
|
||||||
|
expect(consequenceBodies()).toHaveLength(1);
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a malformed From date sends no consequence request and cannot be saved", async () => {
|
||||||
|
await openEditor();
|
||||||
|
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
|
||||||
|
|
||||||
|
setDate("From", "2026-02-30");
|
||||||
|
expect(headline()).toBe("Choose a valid date range");
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
await pastDebounce();
|
||||||
|
expect(consequenceBodies()).toHaveLength(1);
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Save sends the exact POST body, closes the editor and shows the new row", async () => {
|
||||||
|
await openEditor();
|
||||||
|
setDate("From", "2026-09-24");
|
||||||
|
setDate("Through", "2026-09-26");
|
||||||
|
fireEvent.change(screen.getByLabelText("Reason (optional)"), {
|
||||||
|
target: { value: " Trip with grandparents " },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||||
|
const before = calls().length;
|
||||||
|
const schedulesBefore = calls().filter((c) => c.path.endsWith("/presence-schedule")).length;
|
||||||
|
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
const row = await screen.findByTestId("override-50");
|
||||||
|
expect(row.textContent).toContain("Declan away · Sep 24 – 26");
|
||||||
|
expect(row.textContent).toContain("Trip with grandparents");
|
||||||
|
expect(screen.queryByRole("heading", { name: "Presence override" })).toBeNull();
|
||||||
|
|
||||||
|
const after = calls().slice(before);
|
||||||
|
const writes = after.filter((c) => c.method !== "GET");
|
||||||
|
// D-005: saving sends ONLY the create request — never a delete of an
|
||||||
|
// instance or an override. The API's regenerate path keeps completed
|
||||||
|
// instances (tests/test_materialize.py::
|
||||||
|
// test_regenerate_for_definition_rebuilds_horizon_and_preserves_completed).
|
||||||
|
expect(writes).toEqual([
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: OVERRIDES_PATH,
|
||||||
|
body: {
|
||||||
|
child_id: 1,
|
||||||
|
start_date: "2026-09-24",
|
||||||
|
end_date: "2026-09-26",
|
||||||
|
is_present: false,
|
||||||
|
note: "Trip with grandparents",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(after.some((c) => c.method === "DELETE")).toBe(false);
|
||||||
|
// The overrides and the presence schedules are refetched for the grid.
|
||||||
|
expect(after.some((c) => c.method === "GET" && c.path === OVERRIDES_PATH)).toBe(true);
|
||||||
|
expect(calls().filter((c) => c.path.endsWith("/presence-schedule")).length).toBeGreaterThan(
|
||||||
|
schedulesBefore,
|
||||||
|
);
|
||||||
|
// The grid reflects the new override.
|
||||||
|
expect(document.querySelector('[data-date="2026-09-25"]')?.getAttribute("data-state")).toBe(
|
||||||
|
"override-away",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits an empty reason and saves a Home override", async () => {
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Home" }));
|
||||||
|
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
await screen.findByTestId("override-50");
|
||||||
|
const post = calls().find((c) => c.method === "POST" && c.path === OVERRIDES_PATH);
|
||||||
|
expect(post?.body).toEqual({
|
||||||
|
child_id: 1,
|
||||||
|
start_date: TODAY,
|
||||||
|
end_date: TODAY,
|
||||||
|
is_present: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 422 shows the API detail and keeps the form", async () => {
|
||||||
|
createResponse = () => jsonResponse({ detail: "overlaps an existing override" }, 422);
|
||||||
|
await openEditor();
|
||||||
|
setDate("Through", "2026-09-25");
|
||||||
|
fireEvent.change(screen.getByLabelText("Reason (optional)"), { target: { value: "Camp" } });
|
||||||
|
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||||
|
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
const alert = await screen.findByTestId("override-error");
|
||||||
|
expect(alert.textContent).toBe("overlaps an existing override");
|
||||||
|
expect(screen.getByRole("heading", { name: "Presence override" })).toBeTruthy();
|
||||||
|
expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe("2026-09-25");
|
||||||
|
expect((screen.getByLabelText("Reason (optional)") as HTMLInputElement).value).toBe("Camp");
|
||||||
|
expect(saveButton().disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Cancel and Back close the editor without writing", async () => {
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await screen.findByTestId("pattern-1");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Back" }));
|
||||||
|
await screen.findByTestId("pattern-1");
|
||||||
|
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Add opens the editor for the selected child", async () => {
|
||||||
|
render(<ScheduleTab today={TODAY} />);
|
||||||
|
await screen.findByTestId("pattern-1");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||||
|
const kids = within(await screen.findByRole("group", { name: "Child" }));
|
||||||
|
expect(kids.getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe("true");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(consequenceBodies()).toEqual([
|
||||||
|
{ child_id: 2, start_date: TODAY, end_date: TODAY, is_present: false },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the trash button deletes the override only after confirm", async () => {
|
||||||
|
render(<ScheduleTab today={TODAY} />);
|
||||||
|
const row = await screen.findByTestId("override-41");
|
||||||
|
|
||||||
|
fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
|
||||||
|
fireEvent.click(within(row).getByRole("button", { name: "Keep" }));
|
||||||
|
expect(calls().some((c) => c.method === "DELETE")).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
|
||||||
|
const confirm = within(row).getByRole("group", { name: "Confirm delete" });
|
||||||
|
fireEvent.click(within(confirm).getByRole("button", { name: "Delete" }));
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("override-41")).toBeNull());
|
||||||
|
expect(calls().filter((c) => c.method === "DELETE")).toEqual([
|
||||||
|
{ method: "DELETE", path: `${OVERRIDES_PATH}/41`, body: undefined },
|
||||||
|
]);
|
||||||
|
expect(screen.getByText("No overrides for Declan.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OverrideEditor focus", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||||
|
overrides = [...INITIAL_OVERRIDES];
|
||||||
|
consequence = () => jsonResponse({ removed: 4 });
|
||||||
|
createResponse = (body) => {
|
||||||
|
const created = { id: 50, note: null, ...body } as PresenceOverride;
|
||||||
|
overrides = [...overrides, created];
|
||||||
|
return jsonResponse(created, 201);
|
||||||
|
};
|
||||||
|
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function dialog(): HTMLElement {
|
||||||
|
return screen.getByRole("dialog", { name: "Presence override" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addButton(): HTMLElement {
|
||||||
|
return screen.getByRole("button", { name: "Add" });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("moves focus into the editor on open", async () => {
|
||||||
|
await openEditor();
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Presence override" }));
|
||||||
|
expect(dialog().contains(document.activeElement)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Tab and Shift+Tab inside the editor", async () => {
|
||||||
|
await openEditor();
|
||||||
|
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||||
|
const back = screen.getByRole("button", { name: "Back" });
|
||||||
|
|
||||||
|
saveButton().focus();
|
||||||
|
fireEvent.keyDown(saveButton(), { key: "Tab" });
|
||||||
|
expect(document.activeElement).toBe(back);
|
||||||
|
|
||||||
|
fireEvent.keyDown(back, { key: "Tab", shiftKey: true });
|
||||||
|
expect(document.activeElement).toBe(saveButton());
|
||||||
|
|
||||||
|
// From the focused heading (not in the tab order) Shift+Tab wraps to the end.
|
||||||
|
const title = screen.getByRole("heading", { name: "Presence override" });
|
||||||
|
title.focus();
|
||||||
|
fireEvent.keyDown(title, { key: "Tab", shiftKey: true });
|
||||||
|
expect(document.activeElement).toBe(saveButton());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes the tab bar inert while open and restores it on close", async () => {
|
||||||
|
render(<App />);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Schedule" }));
|
||||||
|
await screen.findByTestId("pattern-1");
|
||||||
|
fireEvent.click(addButton());
|
||||||
|
await screen.findByRole("heading", { name: "Presence override" });
|
||||||
|
|
||||||
|
const tabbar = document.querySelector(".admin-tabbar") as HTMLElement;
|
||||||
|
expect(tabbar.hasAttribute("inert")).toBe(true);
|
||||||
|
expect(tabbar.getAttribute("aria-hidden")).toBe("true");
|
||||||
|
expect(dialog().closest("[inert]")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.keyDown(dialog(), { key: "Escape" });
|
||||||
|
await screen.findByTestId("pattern-1");
|
||||||
|
expect(tabbar.hasAttribute("inert")).toBe(false);
|
||||||
|
expect(tabbar.hasAttribute("aria-hidden")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape cancels without writing and returns focus to Add", async () => {
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.keyDown(document.activeElement as HTMLElement, { key: "Escape" });
|
||||||
|
await screen.findByTestId("pattern-1");
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(addButton());
|
||||||
|
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape does nothing while saving; focus returns to Add after the save", async () => {
|
||||||
|
const gate = deferred();
|
||||||
|
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||||
|
const path = new URL(url, "http://x").pathname;
|
||||||
|
if (path === OVERRIDES_PATH && init?.method === "POST") await gate.promise;
|
||||||
|
return routeFetch(url, init);
|
||||||
|
});
|
||||||
|
await openEditor();
|
||||||
|
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.keyDown(dialog(), { key: "Escape" });
|
||||||
|
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||||
|
|
||||||
|
gate.resolve(new Response(null));
|
||||||
|
await screen.findByTestId("override-50");
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(addButton());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Back and Cancel return focus to Add", async () => {
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
await screen.findByTestId("pattern-1");
|
||||||
|
expect(document.activeElement).toBe(addButton());
|
||||||
|
|
||||||
|
fireEvent.click(addButton());
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
|
||||||
|
await screen.findByTestId("pattern-1");
|
||||||
|
expect(document.activeElement).toBe(addButton());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("range validation", () => {
|
||||||
|
it("accepts only strict ISO calendar dates in order", () => {
|
||||||
|
expect(isIsoDate("2026-09-23")).toBe(true);
|
||||||
|
expect(isIsoDate("2026-02-30")).toBe(false);
|
||||||
|
expect(isIsoDate("2026-9-3")).toBe(false);
|
||||||
|
expect(isIsoDate("")).toBe(false);
|
||||||
|
expect(isValidRange("2026-09-23", "2026-09-23")).toBe(true);
|
||||||
|
expect(isValidRange("2026-09-23", "2026-09-22")).toBe(false);
|
||||||
|
expect(isValidRange("2026-09-23", "")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Presence override editor — the pushed screen of design/ADMIN-SPEC.md §6.
|
||||||
|
*
|
||||||
|
* The consequence warning is counted by the API (POST
|
||||||
|
* /presence-overrides/consequence) before anything is saved; Save stays off
|
||||||
|
* until that count is on screen. Saving only ever sends the create request:
|
||||||
|
* the API never removes an instance that already has a completion event (D-005).
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||||
|
import { ApiForbiddenError } from "../api/client";
|
||||||
|
import { ApiRequestError, type AdminChild } from "../api/definitions";
|
||||||
|
import {
|
||||||
|
createPresenceOverride,
|
||||||
|
previewOverrideConsequence,
|
||||||
|
type PresenceOverride,
|
||||||
|
type PresenceOverrideBody,
|
||||||
|
} from "../api/presence";
|
||||||
|
import { dayNumber, isoFromDayNumber } from "./cycle";
|
||||||
|
import { dateRange } from "./format";
|
||||||
|
import { AlertCircleGlyph, ChevronLeftGlyph } from "./glyphs";
|
||||||
|
import "./OverrideEditor.css";
|
||||||
|
|
||||||
|
/** How long the form must sit still before the consequence is recounted. */
|
||||||
|
export const CONSEQUENCE_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
/** Strict "YYYY-MM-DD" naming a real calendar day (no 2026-02-30). */
|
||||||
|
export function isIsoDate(value: string): boolean {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||||
|
return isoFromDayNumber(dayNumber(value)) === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidRange(start: string, end: string): boolean {
|
||||||
|
return isIsoDate(start) && isIsoDate(end) && start <= end;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Consequence =
|
||||||
|
| { kind: "invalid" }
|
||||||
|
| { kind: "counting" }
|
||||||
|
| { kind: "ready"; removed: number }
|
||||||
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
function errorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof ApiForbiddenError) return error.message;
|
||||||
|
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOCUSABLE =
|
||||||
|
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make everything outside `root` inert (siblings of each ancestor up to
|
||||||
|
* <body>), so the tab bar behind the pushed screen cannot take focus.
|
||||||
|
* Returns the undo.
|
||||||
|
*/
|
||||||
|
function inertOutside(root: HTMLElement): () => void {
|
||||||
|
const made: HTMLElement[] = [];
|
||||||
|
for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) {
|
||||||
|
for (const sibling of Array.from(node.parentElement.children)) {
|
||||||
|
if (sibling === node || !(sibling instanceof HTMLElement) || sibling.hasAttribute("inert")) continue;
|
||||||
|
sibling.setAttribute("inert", "");
|
||||||
|
sibling.setAttribute("aria-hidden", "true");
|
||||||
|
made.push(sibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
for (const el of made) {
|
||||||
|
el.removeAttribute("inert");
|
||||||
|
el.removeAttribute("aria-hidden");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tasksPhrase(count: number): string {
|
||||||
|
return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverrideEditorProps {
|
||||||
|
/** Active children, in display order. */
|
||||||
|
childOptions: AdminChild[];
|
||||||
|
initialChildId: number;
|
||||||
|
/** "YYYY-MM-DD"; the date fields start here. */
|
||||||
|
today: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSaved: (override: PresenceOverride) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OverrideEditor({
|
||||||
|
childOptions,
|
||||||
|
initialChildId,
|
||||||
|
today,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
}: OverrideEditorProps) {
|
||||||
|
const [childId, setChildId] = useState(initialChildId);
|
||||||
|
const [isPresent, setIsPresent] = useState(false);
|
||||||
|
const [startDate, setStartDate] = useState(today);
|
||||||
|
const [endDate, setEndDate] = useState(today);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [consequence, setConsequence] = useState<Consequence>({ kind: "counting" });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Every form change bumps the sequence; only the latest request may render.
|
||||||
|
const requestSeq = useRef(0);
|
||||||
|
const screenRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||||
|
|
||||||
|
// Modal focus: move in on open and keep the background out of reach. The
|
||||||
|
// opener hands focus back to its Add control once this screen closes.
|
||||||
|
useEffect(() => {
|
||||||
|
titleRef.current?.focus();
|
||||||
|
return screenRef.current ? inertOutside(screenRef.current) : undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!saving) onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab" || !screenRef.current) return;
|
||||||
|
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||||
|
if (focusable.length === 0) return;
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const current = document.activeElement;
|
||||||
|
const outside = !focusable.includes(current as HTMLElement);
|
||||||
|
if (event.shiftKey && (current === first || outside)) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && (current === last || outside)) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = childOptions.find((c) => c.id === childId) ?? childOptions[0];
|
||||||
|
const rangeValid = isValidRange(startDate, endDate);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const seq = ++requestSeq.current;
|
||||||
|
if (!rangeValid) {
|
||||||
|
setConsequence({ kind: "invalid" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isPresent) {
|
||||||
|
// Keeping a child home removes nothing by definition.
|
||||||
|
setConsequence({ kind: "ready", removed: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConsequence({ kind: "counting" });
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
previewOverrideConsequence({
|
||||||
|
child_id: childId,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
is_present: false,
|
||||||
|
})
|
||||||
|
.then((removed) => {
|
||||||
|
if (seq === requestSeq.current) setConsequence({ kind: "ready", removed });
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (seq !== requestSeq.current) return;
|
||||||
|
setConsequence({
|
||||||
|
kind: "error",
|
||||||
|
message: errorMessage(err, "The upcoming tasks could not be counted. Try again."),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, CONSEQUENCE_DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [childId, startDate, endDate, isPresent, rangeValid]);
|
||||||
|
|
||||||
|
const canSave = rangeValid && consequence.kind === "ready" && !saving;
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!canSave) return;
|
||||||
|
const body: PresenceOverrideBody = {
|
||||||
|
child_id: childId,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
is_present: isPresent,
|
||||||
|
};
|
||||||
|
const reason = note.trim();
|
||||||
|
if (reason) body.note = reason;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
createPresenceOverride(body)
|
||||||
|
.then(onSaved)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setError(errorMessage(err, "The override could not be saved. Check your connection and try again."));
|
||||||
|
setSaving(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let headline: string;
|
||||||
|
let detail: string;
|
||||||
|
const range = rangeValid ? dateRange(startDate, endDate) : "";
|
||||||
|
if (consequence.kind === "invalid") {
|
||||||
|
headline = "Choose a valid date range";
|
||||||
|
detail = "Through must be on or after From.";
|
||||||
|
} else if (consequence.kind === "counting") {
|
||||||
|
headline = "Counting upcoming tasks…";
|
||||||
|
detail = `${child.display_name} away ${range}. Completed days are untouched.`;
|
||||||
|
} else if (consequence.kind === "error") {
|
||||||
|
headline = "Upcoming tasks not counted";
|
||||||
|
detail = consequence.message;
|
||||||
|
} else {
|
||||||
|
headline = `This removes ${tasksPhrase(consequence.removed)}`;
|
||||||
|
detail = isPresent
|
||||||
|
? `${child.display_name} is home ${range}, so no tasks are removed. Completed days are untouched.`
|
||||||
|
: `${child.display_name}'s open tasks ${range} are removed while away. Completed days are untouched.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={screenRef}
|
||||||
|
className="override-screen"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="override-title"
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
<header className="override-header">
|
||||||
|
<button type="button" className="override-back" aria-label="Back" onClick={onCancel}>
|
||||||
|
<ChevronLeftGlyph size={20} />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h2 className="override-title" id="override-title" ref={titleRef} tabIndex={-1}>
|
||||||
|
Presence override
|
||||||
|
</h2>
|
||||||
|
<p className="override-sub">Beats the custody pattern for these dates</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="override-scroll">
|
||||||
|
<section className="override-card" aria-label="Override details">
|
||||||
|
<div className="override-field">
|
||||||
|
<span className="override-label" id="override-label-child">
|
||||||
|
Child
|
||||||
|
</span>
|
||||||
|
<div className="override-segments override-segments--3" role="group" aria-labelledby="override-label-child">
|
||||||
|
{childOptions.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
className="override-segment override-segment--gradient"
|
||||||
|
aria-pressed={c.id === childId}
|
||||||
|
onClick={() => setChildId(c.id)}
|
||||||
|
>
|
||||||
|
{c.display_name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="override-field">
|
||||||
|
<span className="override-label" id="override-label-status">
|
||||||
|
Status for these dates
|
||||||
|
</span>
|
||||||
|
<div className="override-segments override-segments--2" role="group" aria-labelledby="override-label-status">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="override-segment override-segment--ink"
|
||||||
|
aria-pressed={!isPresent}
|
||||||
|
onClick={() => setIsPresent(false)}
|
||||||
|
>
|
||||||
|
Away
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="override-segment override-segment--ink"
|
||||||
|
aria-pressed={isPresent}
|
||||||
|
onClick={() => setIsPresent(true)}
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="override-dates">
|
||||||
|
<label className="override-field">
|
||||||
|
<span className="override-label">From</span>
|
||||||
|
<input
|
||||||
|
className="override-input override-date"
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(event) => setStartDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="override-field">
|
||||||
|
<span className="override-label">Through</span>
|
||||||
|
<input
|
||||||
|
className="override-input override-date"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
min={isIsoDate(startDate) ? startDate : undefined}
|
||||||
|
onChange={(event) => setEndDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="override-field">
|
||||||
|
<span className="override-label">Reason (optional)</span>
|
||||||
|
<input
|
||||||
|
className="override-input"
|
||||||
|
type="text"
|
||||||
|
value={note}
|
||||||
|
onChange={(event) => setNote(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="override-warning"
|
||||||
|
aria-label="Consequence"
|
||||||
|
aria-live="polite"
|
||||||
|
data-testid="override-consequence"
|
||||||
|
data-state={consequence.kind}
|
||||||
|
>
|
||||||
|
<span className="override-warning-glyph">
|
||||||
|
<AlertCircleGlyph />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="override-warning-headline" data-testid="override-consequence-headline">
|
||||||
|
{headline}
|
||||||
|
</p>
|
||||||
|
<p className="override-warning-body">{detail}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="override-error" role="alert" data-testid="override-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="override-footer">
|
||||||
|
<button type="button" className="override-cancel" onClick={onCancel} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" className="override-save" onClick={save} disabled={!canSave}>
|
||||||
|
{saving ? "Saving…" : "Save override"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -377,3 +377,40 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline delete confirmation in an override row */
|
||||||
|
.schedule-confirm {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-confirm-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-confirm-keep,
|
||||||
|
.schedule-confirm-delete {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-confirm-keep {
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
color: var(--nq-a-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-confirm-delete {
|
||||||
|
border: 0;
|
||||||
|
background: var(--nq-a-danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
* Every day's cycle position comes from the anchor date (./cycle.ts), never
|
* Every day's cycle position comes from the anchor date (./cycle.ts), never
|
||||||
* from ISO week numbers or week parity (D-004).
|
* from ISO week numbers or week parity (D-004).
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ApiForbiddenError } from "../api/client";
|
import { ApiForbiddenError } from "../api/client";
|
||||||
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
|
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
|
||||||
import {
|
import {
|
||||||
|
deletePresenceOverride,
|
||||||
fetchPresenceOverrides,
|
fetchPresenceOverrides,
|
||||||
fetchPresenceSchedule,
|
fetchPresenceSchedule,
|
||||||
savePresenceSchedule,
|
savePresenceSchedule,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
ChevronRightGlyph,
|
ChevronRightGlyph,
|
||||||
Trash2Glyph,
|
Trash2Glyph,
|
||||||
} from "./glyphs";
|
} from "./glyphs";
|
||||||
|
import OverrideEditor from "./OverrideEditor";
|
||||||
import "./ScheduleTab.css";
|
import "./ScheduleTab.css";
|
||||||
|
|
||||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||||
@@ -64,6 +66,12 @@ type LoadState =
|
|||||||
| { kind: "error"; message: string }
|
| { kind: "error"; message: string }
|
||||||
| { kind: "ready"; data: Loaded };
|
| { kind: "ready"; data: Loaded };
|
||||||
|
|
||||||
|
type DeleteState =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "confirming"; id: number }
|
||||||
|
| { kind: "deleting"; id: number }
|
||||||
|
| { kind: "error"; id: number; message: string };
|
||||||
|
|
||||||
type SaveState =
|
type SaveState =
|
||||||
| { kind: "idle" }
|
| { kind: "idle" }
|
||||||
| { kind: "saving" }
|
| { kind: "saving" }
|
||||||
@@ -85,10 +93,19 @@ async function loadSchedule(): Promise<Loaded> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveErrorMessage(error: unknown): string {
|
function loadErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof ApiForbiddenError
|
||||||
|
? error.message
|
||||||
|
: "The schedule could not be loaded. Check your connection and try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveErrorMessage(
|
||||||
|
error: unknown,
|
||||||
|
fallback = "The pattern could not be saved. Check your connection and try again.",
|
||||||
|
): string {
|
||||||
if (error instanceof ApiForbiddenError) return error.message;
|
if (error instanceof ApiForbiddenError) return error.message;
|
||||||
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
||||||
return "The pattern could not be saved. Check your connection and try again.";
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScreenHeader({ sub }: { sub: string }) {
|
function ScreenHeader({ sub }: { sub: string }) {
|
||||||
@@ -185,7 +202,18 @@ function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onTo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OverrideRow({ override, childName }: { override: PresenceOverride; childName: string }) {
|
interface OverrideRowProps {
|
||||||
|
override: PresenceOverride;
|
||||||
|
childName: string;
|
||||||
|
deletion: DeleteState;
|
||||||
|
onAskDelete: () => void;
|
||||||
|
onKeep: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDelete }: OverrideRowProps) {
|
||||||
|
const mine =
|
||||||
|
(deletion.kind === "confirming" || deletion.kind === "deleting") && deletion.id === override.id;
|
||||||
return (
|
return (
|
||||||
<li className="schedule-row" data-testid={`override-${override.id}`}>
|
<li className="schedule-row" data-testid={`override-${override.id}`}>
|
||||||
<span className="schedule-row-glyph">
|
<span className="schedule-row-glyph">
|
||||||
@@ -198,10 +226,36 @@ function OverrideRow({ override, childName }: { override: PresenceOverride; chil
|
|||||||
</div>
|
</div>
|
||||||
<div className="schedule-row-meta">{override.note || "No reason given"}</div>
|
<div className="schedule-row-meta">{override.note || "No reason given"}</div>
|
||||||
</div>
|
</div>
|
||||||
{/* TODO(4ae0c879): the override editor wires delete; inert until then. */}
|
{mine ? (
|
||||||
<button type="button" className="schedule-icon-action" aria-label="Delete override">
|
<div className="schedule-confirm" role="group" aria-label="Confirm delete">
|
||||||
<Trash2Glyph />
|
<span className="schedule-confirm-text">Delete override?</span>
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="schedule-confirm-keep"
|
||||||
|
onClick={onKeep}
|
||||||
|
disabled={deletion.kind === "deleting"}
|
||||||
|
>
|
||||||
|
Keep
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="schedule-confirm-delete"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={deletion.kind === "deleting"}
|
||||||
|
>
|
||||||
|
{deletion.kind === "deleting" ? "Deleting…" : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="schedule-icon-action"
|
||||||
|
aria-label="Delete override"
|
||||||
|
onClick={onAskDelete}
|
||||||
|
>
|
||||||
|
<Trash2Glyph />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -218,6 +272,23 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
|
|||||||
const [month, setMonth] = useState(() => monthStart(today));
|
const [month, setMonth] = useState(() => monthStart(today));
|
||||||
const [drafts, setDrafts] = useState<Record<number, PresenceScheduleBody>>({});
|
const [drafts, setDrafts] = useState<Record<number, PresenceScheduleBody>>({});
|
||||||
const [save, setSave] = useState<SaveState>({ kind: "idle" });
|
const [save, setSave] = useState<SaveState>({ kind: "idle" });
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" });
|
||||||
|
const addRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const editorWasOpen = useRef(false);
|
||||||
|
|
||||||
|
// The editor replaces this screen, so Add is remounted when it closes;
|
||||||
|
// hand focus back to it then.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editorWasOpen.current && !editorOpen) addRef.current?.focus();
|
||||||
|
editorWasOpen.current = editorOpen;
|
||||||
|
}, [editorOpen]);
|
||||||
|
|
||||||
|
/** Reload children, schedules and overrides in place (no loading flash). */
|
||||||
|
const refresh = () =>
|
||||||
|
loadSchedule()
|
||||||
|
.then((data) => setState({ kind: "ready", data }))
|
||||||
|
.catch((error: unknown) => setState({ kind: "error", message: loadErrorMessage(error) }));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -230,17 +301,30 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
|
|||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const message =
|
setState({ kind: "error", message: loadErrorMessage(error) });
|
||||||
error instanceof ApiForbiddenError
|
|
||||||
? error.message
|
|
||||||
: "The schedule could not be loaded. Check your connection and try again.";
|
|
||||||
setState({ kind: "error", message });
|
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [attempt]);
|
}, [attempt]);
|
||||||
|
|
||||||
|
if (editorOpen && state.kind === "ready" && state.data.children.length > 0) {
|
||||||
|
const { children } = state.data;
|
||||||
|
return (
|
||||||
|
<OverrideEditor
|
||||||
|
childOptions={children}
|
||||||
|
initialChildId={children.find((c) => c.id === selectedId)?.id ?? children[0].id}
|
||||||
|
today={today}
|
||||||
|
onCancel={() => setEditorOpen(false)}
|
||||||
|
onSaved={(override) => {
|
||||||
|
setEditorOpen(false);
|
||||||
|
setSelectedId(override.child_id);
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let sub = "";
|
let sub = "";
|
||||||
let body;
|
let body;
|
||||||
if (state.kind === "loading") {
|
if (state.kind === "loading") {
|
||||||
@@ -285,6 +369,22 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
|
|||||||
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
|
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
|
||||||
edit({ ...schedule, pattern: togglePatternDay(schedule.pattern, week, weekdayOf(date)) });
|
edit({ ...schedule, pattern: togglePatternDay(schedule.pattern, week, weekdayOf(date)) });
|
||||||
};
|
};
|
||||||
|
const confirmDelete = (id: number) => {
|
||||||
|
setDeletion({ kind: "deleting", id });
|
||||||
|
deletePresenceOverride(id)
|
||||||
|
.then(() => refresh())
|
||||||
|
.then(() => setDeletion({ kind: "idle" }))
|
||||||
|
.catch((error: unknown) =>
|
||||||
|
setDeletion({
|
||||||
|
kind: "error",
|
||||||
|
id,
|
||||||
|
message: saveErrorMessage(
|
||||||
|
error,
|
||||||
|
"The override could not be deleted. Check your connection and try again.",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
const childId = child.id;
|
const childId = child.id;
|
||||||
@@ -407,20 +507,37 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
|
|||||||
|
|
||||||
<div className="schedule-section-head">
|
<div className="schedule-section-head">
|
||||||
<h3 className="schedule-section-label">Overrides</h3>
|
<h3 className="schedule-section-label">Overrides</h3>
|
||||||
{/* TODO(4ae0c879): the override editor wires Add; inert until then. */}
|
<button
|
||||||
<button type="button" className="schedule-text-action">
|
ref={addRef}
|
||||||
|
type="button"
|
||||||
|
className="schedule-text-action"
|
||||||
|
onClick={() => setEditorOpen(true)}
|
||||||
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{childOverrides.length > 0 ? (
|
{childOverrides.length > 0 ? (
|
||||||
<ul className="schedule-card schedule-list" aria-label="Overrides">
|
<ul className="schedule-card schedule-list" aria-label="Overrides">
|
||||||
{childOverrides.map((override) => (
|
{childOverrides.map((override) => (
|
||||||
<OverrideRow key={override.id} override={override} childName={child.display_name} />
|
<OverrideRow
|
||||||
|
key={override.id}
|
||||||
|
override={override}
|
||||||
|
childName={child.display_name}
|
||||||
|
deletion={deletion}
|
||||||
|
onAskDelete={() => setDeletion({ kind: "confirming", id: override.id })}
|
||||||
|
onKeep={() => setDeletion({ kind: "idle" })}
|
||||||
|
onDelete={() => confirmDelete(override.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<div className="schedule-card schedule-empty">No overrides for {child.display_name}.</div>
|
<div className="schedule-card schedule-empty">No overrides for {child.display_name}.</div>
|
||||||
)}
|
)}
|
||||||
|
{deletion.kind === "error" ? (
|
||||||
|
<p className="schedule-save-status schedule-save-status--error" role="alert">
|
||||||
|
{deletion.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,3 +74,12 @@ export function Trash2Glyph({ size = 16 }: { size?: number }) {
|
|||||||
</Glyph>
|
</Glyph>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<Glyph size={size}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 8v4M12 16h.01" />
|
||||||
|
</Glyph>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user