The dropdown caps at 264px and a category registry easily exceeds it; arrow navigation now scrolls the armed row into view with block: nearest so the list follows the keyboard in both directions.
979 lines
28 KiB
TypeScript
979 lines
28 KiB
TypeScript
import { useEffect, useId, useRef, useState } from "react";
|
|
import type { ReactNode } from "react";
|
|
import {
|
|
X,
|
|
Inbox,
|
|
AlertCircle,
|
|
CalendarDays,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Plus,
|
|
} from "lucide-react";
|
|
import type { Category, Dataset, Filter, State, VerifiedModel } from "./api";
|
|
import {
|
|
categoryPath,
|
|
DEFAULT_MONTHS,
|
|
defaultFilter,
|
|
monthStart,
|
|
request,
|
|
yearStart,
|
|
} from "./api";
|
|
export function Modal({
|
|
title,
|
|
children,
|
|
close,
|
|
wide = false,
|
|
}: {
|
|
title: string;
|
|
children: ReactNode;
|
|
close: () => void;
|
|
wide?: boolean;
|
|
}) {
|
|
const ref = useRef<HTMLDialogElement>(null);
|
|
const titleID = useId();
|
|
useEffect(() => {
|
|
const dialog = ref.current;
|
|
dialog?.showModal();
|
|
return () => dialog?.close();
|
|
}, []);
|
|
return (
|
|
<dialog
|
|
ref={ref}
|
|
aria-labelledby={titleID}
|
|
className={wide ? "modal wide" : "modal"}
|
|
onCancel={(e) => {
|
|
e.preventDefault();
|
|
close();
|
|
}}
|
|
>
|
|
<div className="modal-header">
|
|
<h2 id={titleID}>{title}</h2>
|
|
<button
|
|
className="icon-button"
|
|
aria-label="Close dialog"
|
|
onClick={close}
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
{children}
|
|
</dialog>
|
|
);
|
|
}
|
|
// ModelOptions loads the server-verified model list once and renders it as a
|
|
// datalist: the input stays free text so an unlisted model is still usable
|
|
// when the catalog is unreachable.
|
|
export function ModelOptions({ id }: { id: string }) {
|
|
const [models, setModels] = useState<VerifiedModel[]>([]);
|
|
useEffect(() => {
|
|
request<VerifiedModel[]>("/api/models")
|
|
.then(setModels)
|
|
.catch(() => {});
|
|
}, []);
|
|
return (
|
|
<datalist id={id}>
|
|
{models.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.name}
|
|
</option>
|
|
))}
|
|
</datalist>
|
|
);
|
|
}
|
|
|
|
export function Field({
|
|
label,
|
|
children,
|
|
hint,
|
|
}: {
|
|
label: string;
|
|
children: ReactNode;
|
|
hint?: string;
|
|
}) {
|
|
return (
|
|
<label className="field">
|
|
<span>{label}</span>
|
|
{children}
|
|
{hint && <small>{hint}</small>}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export interface ComboOption {
|
|
value: string;
|
|
label: string;
|
|
icon?: ReactNode;
|
|
}
|
|
// ComboCreate is one "create it now" row a Combobox offers when the typed
|
|
// text matches nothing: running it is expected to persist the new entity and
|
|
// select it through the caller's own onChange.
|
|
export interface ComboCreate {
|
|
key: string;
|
|
label: string;
|
|
run: () => Promise<void> | void;
|
|
}
|
|
// Combobox is a free-text input that autocompletes against a fixed option
|
|
// list: typing filters by label, Enter takes the exact or only match, and
|
|
// picking an option reports its value. The caller keeps working with stable
|
|
// ids while the user only ever sees names.
|
|
export function Combobox({
|
|
options,
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
disabled = false,
|
|
required = false,
|
|
adornment,
|
|
emptyText = "No matches.",
|
|
create,
|
|
}: {
|
|
options: ComboOption[];
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
required?: boolean;
|
|
adornment?: ReactNode;
|
|
emptyText?: string;
|
|
create?: (text: string) => ComboCreate[];
|
|
}) {
|
|
const [creating, setCreating] = useState(false);
|
|
const [createError, setCreateError] = useState("");
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
// Index into the interactive rows (matches first, then create rows); -1
|
|
// means no row is armed and Enter falls back to exact/single-match logic.
|
|
const [active, setActive] = useState(-1);
|
|
const listID = useId();
|
|
const filter = query.trim().toLowerCase();
|
|
const matches = options.filter((o) => o.label.toLowerCase().includes(filter));
|
|
const exact = filter
|
|
? matches.find((o) => o.label.toLowerCase() === filter)
|
|
: undefined;
|
|
const shown = exact
|
|
? [exact, ...matches.filter((o) => o !== exact).slice(0, 59)]
|
|
: matches.slice(0, 60);
|
|
const selected = options.find((o) => o.value === value);
|
|
const creations =
|
|
create && filter && !exact && !disabled ? create(query.trim()) : [];
|
|
const total = shown.length + creations.length;
|
|
const cursor = active < total ? active : -1;
|
|
// The dropdown scrolls at 264px; keep the armed row visible while
|
|
// arrowing through a long category list.
|
|
useEffect(() => {
|
|
if (cursor < 0) return;
|
|
document
|
|
.getElementById(`${listID}-${cursor}`)
|
|
?.scrollIntoView({ block: "nearest" });
|
|
}, [cursor, listID]);
|
|
const pick = (v: string) => {
|
|
onChange(v);
|
|
setOpen(false);
|
|
};
|
|
const runCreate = async (c: ComboCreate) => {
|
|
if (creating) return;
|
|
setCreating(true);
|
|
setCreateError("");
|
|
try {
|
|
await c.run();
|
|
setOpen(false);
|
|
} catch (err) {
|
|
setCreateError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
return (
|
|
<div className="combo">
|
|
<input
|
|
required={required}
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
aria-autocomplete="list"
|
|
aria-controls={open ? listID : undefined}
|
|
aria-activedescendant={
|
|
open && cursor >= 0 ? `${listID}-${cursor}` : undefined
|
|
}
|
|
disabled={disabled}
|
|
value={open ? query : (selected?.label ?? value)}
|
|
placeholder={placeholder}
|
|
onFocus={() => {
|
|
setQuery("");
|
|
setActive(-1);
|
|
setOpen(true);
|
|
}}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
setCreateError("");
|
|
setActive(-1);
|
|
setOpen(true);
|
|
}}
|
|
onBlur={() => setOpen(false)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") setOpen(false);
|
|
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && open && total) {
|
|
e.preventDefault();
|
|
setActive(
|
|
e.key === "ArrowDown"
|
|
? (cursor + 1) % total
|
|
: (cursor <= 0 ? total : cursor) - 1,
|
|
);
|
|
}
|
|
if (e.key === "Enter" && open) {
|
|
e.preventDefault();
|
|
if (cursor >= 0 && cursor < shown.length) pick(shown[cursor].value);
|
|
else if (cursor >= shown.length)
|
|
void runCreate(creations[cursor - shown.length]);
|
|
else {
|
|
const hit = exact ?? (shown.length === 1 ? shown[0] : undefined);
|
|
if (hit) pick(hit.value);
|
|
// Without an armed row, Enter creates only when nothing
|
|
// matches at all: minting from a half-typed name is too easy.
|
|
else if (!shown.length && creations.length === 1)
|
|
void runCreate(creations[0]);
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
{adornment && !open && (
|
|
<span className="combo-adornment">{adornment}</span>
|
|
)}
|
|
{open && (
|
|
<ul className="combo-options" role="listbox" id={listID}>
|
|
{shown.map((o, i) => (
|
|
<li key={o.value}>
|
|
<button
|
|
type="button"
|
|
id={`${listID}-${i}`}
|
|
className={
|
|
i === cursor ? "combo-option active" : "combo-option"
|
|
}
|
|
role="option"
|
|
aria-selected={o.value === value}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => pick(o.value)}
|
|
>
|
|
{o.icon}
|
|
<span>{o.label}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
{creations.map((c, i) => (
|
|
<li key={c.key}>
|
|
<button
|
|
type="button"
|
|
id={`${listID}-${shown.length + i}`}
|
|
className={
|
|
shown.length + i === cursor
|
|
? "combo-option create active"
|
|
: "combo-option create"
|
|
}
|
|
role="option"
|
|
aria-selected={false}
|
|
disabled={creating}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => void runCreate(c)}
|
|
>
|
|
<Plus size={14} />
|
|
<span>{creating ? "Creating…" : c.label}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
{createError && (
|
|
<li className="combo-empty error" role="alert">
|
|
{createError}
|
|
</li>
|
|
)}
|
|
{shown.length === 0 && creations.length === 0 && !createError && (
|
|
<li className="combo-empty">{emptyText}</li>
|
|
)}
|
|
{matches.length > shown.length && (
|
|
<li className="combo-empty">
|
|
{matches.length - shown.length} more — keep typing to narrow down.
|
|
</li>
|
|
)}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Dates are handled as calendar days, never as instants: every helper works on
|
|
// the ISO string's integer parts so a browser time zone can never shift a
|
|
// booking date. "Sept" follows the four-letter form used in the journal UI.
|
|
const MONTHS = [
|
|
"Jan",
|
|
"Feb",
|
|
"Mar",
|
|
"Apr",
|
|
"May",
|
|
"Jun",
|
|
"Jul",
|
|
"Aug",
|
|
"Sept",
|
|
"Oct",
|
|
"Nov",
|
|
"Dec",
|
|
];
|
|
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
|
interface Month {
|
|
year: number;
|
|
month: number;
|
|
}
|
|
function dayParts(iso: string): (Month & { day: number }) | null {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
|
|
const [year, month, day] = iso.split("-").map(Number);
|
|
if (month < 1 || month > 12 || day < 1 || day > daysInMonth({ year, month }))
|
|
return null;
|
|
return { year, month, day };
|
|
}
|
|
function isoDay({ year, month }: Month, day: number) {
|
|
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
}
|
|
function daysInMonth({ year, month }: Month) {
|
|
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
}
|
|
// Monday-first column index of the first day of the month.
|
|
function firstWeekday({ year, month }: Month) {
|
|
return (new Date(Date.UTC(year, month - 1, 1)).getUTCDay() + 6) % 7;
|
|
}
|
|
function shiftMonth({ year, month }: Month, by: number): Month {
|
|
const zero = year * 12 + (month - 1) + by;
|
|
return { year: Math.floor(zero / 12), month: (zero % 12) + 1 };
|
|
}
|
|
// formatDay renders an ISO calendar day as "09 Sept 2026". Unparseable input is
|
|
// returned unchanged so an unexpected stored value stays visible instead of
|
|
// being replaced by an invented date.
|
|
function formatDay(iso: string): string {
|
|
const parts = dayParts(iso);
|
|
if (!parts) return iso;
|
|
return `${String(parts.day).padStart(2, "0")} ${MONTHS[parts.month - 1]} ${parts.year}`;
|
|
}
|
|
|
|
// DateField picks a calendar day by month name instead of the browser's
|
|
// numeric date control, which renders day and month ambiguously across
|
|
// locales. The value stays an ISO day, so filters and API payloads are
|
|
// unchanged. min/max are inclusive ISO days.
|
|
export function DateField({
|
|
label,
|
|
value,
|
|
onChange,
|
|
min,
|
|
max,
|
|
hint,
|
|
clearable = false,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
onChange: (day: string) => void;
|
|
min?: string;
|
|
max?: string;
|
|
hint?: string;
|
|
clearable?: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const clock = new Date();
|
|
const now = isoDay(
|
|
{ year: clock.getFullYear(), month: clock.getMonth() + 1 },
|
|
clock.getDate(),
|
|
);
|
|
const selected = dayParts(value);
|
|
const [view, setView] = useState<Month>(
|
|
() => selected ?? dayParts(now) ?? { year: clock.getFullYear(), month: 1 },
|
|
);
|
|
const blocked = (day: string) => (!!min && day < min) || (!!max && day > max);
|
|
const choose = (day: string) => {
|
|
onChange(day);
|
|
setOpen(false);
|
|
};
|
|
const years = [];
|
|
const firstYear = (min ? dayParts(min)?.year : undefined) ?? view.year - 12;
|
|
const lastYear = (max ? dayParts(max)?.year : undefined) ?? view.year + 2;
|
|
for (
|
|
let year = Math.min(firstYear, view.year);
|
|
year <= Math.max(lastYear, view.year);
|
|
year++
|
|
)
|
|
years.push(year);
|
|
const days = [];
|
|
for (let day = 1; day <= daysInMonth(view); day++) days.push(day);
|
|
// Dismissal must not depend on focus: a click does not focus a button on
|
|
// every platform, and Escape then never reaches the popover. preventDefault
|
|
// keeps an enclosing dialog open when Escape only closes this picker.
|
|
const box = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const outside = (e: PointerEvent) => {
|
|
if (!box.current?.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
const escape = (e: KeyboardEvent) => {
|
|
if (e.key !== "Escape") return;
|
|
e.preventDefault();
|
|
setOpen(false);
|
|
};
|
|
document.addEventListener("pointerdown", outside);
|
|
document.addEventListener("keydown", escape);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", outside);
|
|
document.removeEventListener("keydown", escape);
|
|
};
|
|
}, [open]);
|
|
return (
|
|
<Field label={label} hint={hint}>
|
|
<div className="date-select" ref={box}>
|
|
<button
|
|
type="button"
|
|
className="date-trigger"
|
|
aria-haspopup="dialog"
|
|
aria-expanded={open}
|
|
onClick={() => {
|
|
if (!open) setView(selected ?? dayParts(now) ?? view);
|
|
setOpen(!open);
|
|
}}
|
|
>
|
|
<CalendarDays size={14} />
|
|
<span className={value ? "" : "date-placeholder"}>
|
|
{value ? formatDay(value) : "Any date"}
|
|
</span>
|
|
</button>
|
|
{open && (
|
|
<div className="date-popover" role="dialog" aria-label={label}>
|
|
<div className="date-nav">
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
aria-label="Previous month"
|
|
onClick={() => setView(shiftMonth(view, -1))}
|
|
>
|
|
<ChevronLeft size={15} />
|
|
</button>
|
|
<select
|
|
aria-label="Month"
|
|
value={view.month}
|
|
onChange={(e) =>
|
|
setView({ ...view, month: Number(e.target.value) })
|
|
}
|
|
>
|
|
{MONTHS.map((name, index) => (
|
|
<option key={name} value={index + 1}>
|
|
{name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
aria-label="Year"
|
|
value={view.year}
|
|
onChange={(e) =>
|
|
setView({ ...view, year: Number(e.target.value) })
|
|
}
|
|
>
|
|
{years.map((year) => (
|
|
<option key={year} value={year}>
|
|
{year}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
aria-label="Next month"
|
|
onClick={() => setView(shiftMonth(view, 1))}
|
|
>
|
|
<ChevronRight size={15} />
|
|
</button>
|
|
</div>
|
|
<div className="date-grid">
|
|
{WEEKDAYS.map((weekday) => (
|
|
<span key={weekday} className="date-weekday">
|
|
{weekday}
|
|
</span>
|
|
))}
|
|
{Array.from({ length: firstWeekday(view) }, (_, i) => (
|
|
<span key={`pad${i}`} />
|
|
))}
|
|
{days.map((day) => {
|
|
const iso = isoDay(view, day);
|
|
return (
|
|
<button
|
|
key={day}
|
|
type="button"
|
|
className={`date-day${iso === value ? " selected" : ""}${iso === now ? " today" : ""}`}
|
|
disabled={blocked(iso)}
|
|
aria-current={iso === value ? "date" : undefined}
|
|
aria-label={formatDay(iso)}
|
|
onClick={() => choose(iso)}
|
|
>
|
|
{day}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="date-actions">
|
|
<button
|
|
type="button"
|
|
className="button subtle"
|
|
disabled={blocked(now)}
|
|
onClick={() => choose(now)}
|
|
>
|
|
Today
|
|
</button>
|
|
{clearable && value && (
|
|
<button
|
|
type="button"
|
|
className="button subtle"
|
|
onClick={() => choose("")}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Field>
|
|
);
|
|
}
|
|
export function ErrorMessage({ error }: { error: string }) {
|
|
return error ? (
|
|
<div className="alert error" role="alert">
|
|
<AlertCircle size={18} />
|
|
<span>{error}</span>
|
|
</div>
|
|
) : null;
|
|
}
|
|
export function Empty({
|
|
title,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
children?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="empty">
|
|
<div className="empty-icon">
|
|
<Inbox size={30} />
|
|
</div>
|
|
<h3>{title}</h3>
|
|
<div>{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
// createTag persists a new tag and returns its server-minted id, found by
|
|
// diffing the returned state against the dataset the caller rendered with.
|
|
export async function createTag(
|
|
mutate: Mutate,
|
|
data: Dataset,
|
|
name: string,
|
|
): Promise<string> {
|
|
const next = await mutate(
|
|
"/api/tags",
|
|
{ tag: { id: "", name, hint: "" } },
|
|
`Tag "${name}" created`,
|
|
);
|
|
const created = next.data.tags.find(
|
|
(t) => !data.tags.some((o) => o.id === t.id),
|
|
);
|
|
if (!created)
|
|
throw new Error(`The server did not return the new tag "${name}".`);
|
|
return created.id;
|
|
}
|
|
export async function createCategory(
|
|
mutate: Mutate,
|
|
data: Dataset,
|
|
category: { name: string; parent_id: string; kind: string },
|
|
): Promise<string> {
|
|
const next = await mutate(
|
|
"/api/categories",
|
|
{ category: { id: "", hint: "", ...category } },
|
|
`Category "${category.name}" created`,
|
|
);
|
|
const created = next.data.categories.find(
|
|
(c) => !data.categories.some((o) => o.id === c.id),
|
|
);
|
|
if (!created)
|
|
throw new Error(
|
|
`The server did not return the new category "${category.name}".`,
|
|
);
|
|
return created.id;
|
|
}
|
|
export function TagPicker({
|
|
data,
|
|
value,
|
|
onChange,
|
|
mutate,
|
|
}: {
|
|
data: Dataset;
|
|
value: string[];
|
|
onChange: (ids: string[]) => void;
|
|
mutate?: Mutate;
|
|
}) {
|
|
const [draft, setDraft] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const add = async () => {
|
|
const name = draft.trim();
|
|
if (!name || busy || !mutate) return;
|
|
// An existing tag of the same name is checked instead of duplicated.
|
|
const existing = data.tags.find(
|
|
(t) => t.name.toLowerCase() === name.toLowerCase(),
|
|
);
|
|
if (existing) {
|
|
if (!value.includes(existing.id)) onChange([...value, existing.id]);
|
|
setDraft("");
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const id = await createTag(mutate, data, name);
|
|
onChange([...value, id]);
|
|
setDraft("");
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
return (
|
|
<fieldset className="tag-picker">
|
|
<legend>Tags</legend>
|
|
{data.tags.map((tag) => (
|
|
<label className="check-chip" key={tag.id}>
|
|
<input
|
|
type="checkbox"
|
|
checked={value.includes(tag.id)}
|
|
onChange={(e) =>
|
|
onChange(
|
|
e.target.checked
|
|
? [...value, tag.id]
|
|
: value.filter((id) => id !== tag.id),
|
|
)
|
|
}
|
|
/>
|
|
{tag.name}
|
|
</label>
|
|
))}
|
|
{!data.tags.length && !mutate && (
|
|
<small>No tags yet. Create them in Tags.</small>
|
|
)}
|
|
{mutate && (
|
|
<span className="tag-add">
|
|
<input
|
|
value={draft}
|
|
maxLength={200}
|
|
placeholder="New tag"
|
|
aria-label="New tag name"
|
|
disabled={busy}
|
|
onChange={(e) => {
|
|
setDraft(e.target.value);
|
|
setError("");
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
void add();
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
aria-label="Create tag"
|
|
disabled={busy || !draft.trim()}
|
|
onClick={() => void add()}
|
|
>
|
|
<Plus size={15} />
|
|
</button>
|
|
</span>
|
|
)}
|
|
{error && <small className="tag-add-error">{error}</small>}
|
|
</fieldset>
|
|
);
|
|
}
|
|
export function CategoryOptions({
|
|
data,
|
|
kind,
|
|
exclude = [],
|
|
}: {
|
|
data: Dataset;
|
|
kind?: string;
|
|
exclude?: string[];
|
|
}) {
|
|
return (
|
|
<>
|
|
{data.categories
|
|
.filter((c) => (!kind || c.kind === kind) && !exclude.includes(c.id))
|
|
.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{categoryPath(data, c.id)}
|
|
</option>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
// CategoryCombobox is the one category picker: options are full paths, and
|
|
// with a mutate handle an unmatched name can be created in place. A bare name
|
|
// lands under the kind's root; "Parent / Name" creates under that parent.
|
|
// leavesOnly matches the server rule that assigned categories must be leaves;
|
|
// a freshly created category is always a leaf.
|
|
export function CategoryCombobox({
|
|
data,
|
|
value,
|
|
onChange,
|
|
mutate,
|
|
kind,
|
|
leavesOnly = false,
|
|
exclude = [],
|
|
emptyLabel,
|
|
required = false,
|
|
disabled = false,
|
|
placeholder = "Search categories",
|
|
}: {
|
|
data: Dataset;
|
|
value: string;
|
|
onChange: (id: string) => void;
|
|
mutate?: Mutate;
|
|
kind?: string;
|
|
leavesOnly?: boolean;
|
|
exclude?: string[];
|
|
emptyLabel?: string;
|
|
required?: boolean;
|
|
disabled?: boolean;
|
|
placeholder?: string;
|
|
}) {
|
|
const parents = new Set(
|
|
data.categories.map((c) => c.parent_id).filter(Boolean),
|
|
);
|
|
const eligible = (c: Category) =>
|
|
(!kind || c.kind === kind) && !exclude.includes(c.id);
|
|
const options: ComboOption[] = data.categories
|
|
.filter((c) => eligible(c) && (!leavesOnly || !parents.has(c.id)))
|
|
.map((c) => ({ value: c.id, label: categoryPath(data, c.id) }));
|
|
if (emptyLabel) options.unshift({ value: "", label: emptyLabel });
|
|
const pathOf = (id: string) => categoryPath(data, id).toLowerCase();
|
|
const taken = (parentID: string, name: string) => {
|
|
const full = `${parentID ? pathOf(parentID) + " / " : ""}${name.toLowerCase()}`;
|
|
return data.categories.some((c) => pathOf(c.id) === full);
|
|
};
|
|
const create = (text: string): ComboCreate[] => {
|
|
// Text aiming at the empty option ("No parent…") is a selection, not a
|
|
// new category name.
|
|
if (!mutate || emptyLabel?.toLowerCase().includes(text.toLowerCase()))
|
|
return [];
|
|
const segments = text
|
|
.split("/")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
if (!segments.length) return [];
|
|
const name = segments[segments.length - 1];
|
|
const row = (parent: Category): ComboCreate => ({
|
|
key: parent.id,
|
|
label: `Create "${name}" in ${categoryPath(data, parent.id)}`,
|
|
run: async () =>
|
|
onChange(
|
|
await createCategory(mutate, data, {
|
|
name,
|
|
parent_id: parent.id,
|
|
kind: parent.kind,
|
|
}),
|
|
),
|
|
});
|
|
if (segments.length > 1) {
|
|
const prefix = segments.slice(0, -1).join(" / ").toLowerCase();
|
|
const parent = data.categories.find(
|
|
(c) => eligible(c) && pathOf(c.id) === prefix,
|
|
);
|
|
return parent && !taken(parent.id, name) ? [row(parent)] : [];
|
|
}
|
|
return data.categories
|
|
.filter((c) => !c.parent_id && eligible(c) && !taken(c.id, name))
|
|
.map(row);
|
|
};
|
|
return (
|
|
<Combobox
|
|
options={options}
|
|
value={value}
|
|
onChange={onChange}
|
|
required={required}
|
|
disabled={disabled}
|
|
placeholder={placeholder}
|
|
emptyText={
|
|
mutate
|
|
? "No matching category. Type a name to create it."
|
|
: "No matching category."
|
|
}
|
|
create={create}
|
|
/>
|
|
);
|
|
}
|
|
export function Filters({
|
|
data,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
data: Dataset;
|
|
value: Filter;
|
|
onChange: (filter: Filter) => void;
|
|
}) {
|
|
const update = (key: keyof Filter, text: string) =>
|
|
onChange({ ...value, [key]: text });
|
|
const currencies = Array.from(
|
|
new Set([
|
|
...data.accounts.map((a) => a.currency),
|
|
...data.transactions.map((t) => t.facts.currency),
|
|
]),
|
|
).sort();
|
|
// Presets leave `to` open so the window always reaches today; the explicit
|
|
// date fields below stay authoritative for anything narrower.
|
|
const ranges = [
|
|
...[1, 3, DEFAULT_MONTHS, 12].map((months) => ({
|
|
label: `${months}M`,
|
|
title: months === 1 ? "This month" : `Last ${months} months`,
|
|
from: monthStart(months - 1),
|
|
to: "",
|
|
})),
|
|
{ label: "YTD", title: "Year to date", from: yearStart(), to: "" },
|
|
{ label: "All", title: "All time", from: "", to: "" },
|
|
];
|
|
return (
|
|
<div className="filter-bar">
|
|
<div className="range-row">
|
|
<span className="eyebrow">Period</span>
|
|
<div className="chips">
|
|
{ranges.map((range) => {
|
|
const active = value.from === range.from && value.to === range.to;
|
|
return (
|
|
<button
|
|
key={range.label}
|
|
type="button"
|
|
className={`chip ${active ? "active" : ""}`}
|
|
aria-pressed={active}
|
|
title={range.title}
|
|
aria-label={range.title}
|
|
onClick={() =>
|
|
onChange({ ...value, from: range.from, to: range.to })
|
|
}
|
|
>
|
|
{range.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<button
|
|
className="button subtle filter-reset"
|
|
onClick={() => onChange(defaultFilter())}
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
<div className="filters">
|
|
<DateField
|
|
label="From"
|
|
clearable
|
|
value={value.from}
|
|
max={value.to || undefined}
|
|
onChange={(day) => update("from", day)}
|
|
/>
|
|
<DateField
|
|
label="To"
|
|
clearable
|
|
value={value.to}
|
|
min={value.from || undefined}
|
|
onChange={(day) => update("to", day)}
|
|
/>
|
|
<Field label="Currency">
|
|
<select
|
|
value={value.currency}
|
|
onChange={(e) => update("currency", e.target.value)}
|
|
>
|
|
<option value="">All currencies</option>
|
|
{currencies.map((c) => (
|
|
<option key={c}>{c}</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field label="Account">
|
|
<select
|
|
value={value.account_id}
|
|
onChange={(e) => update("account_id", e.target.value)}
|
|
>
|
|
<option value="">All accounts</option>
|
|
{data.accounts.map((a) => (
|
|
<option value={a.id} key={a.id}>
|
|
{a.display_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field label="Category">
|
|
<select
|
|
value={value.category_id}
|
|
onChange={(e) => update("category_id", e.target.value)}
|
|
>
|
|
<option value="">All categories</option>
|
|
<CategoryOptions data={data} />
|
|
</select>
|
|
</Field>
|
|
<Field label="Tag">
|
|
<select
|
|
value={value.tag_id}
|
|
onChange={(e) => update("tag_id", e.target.value)}
|
|
>
|
|
<option value="">All tags</option>
|
|
{data.tags.map((t) => (
|
|
<option value={t.id} key={t.id}>
|
|
{t.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field label="Merchant">
|
|
<select
|
|
value={value.merchant_id}
|
|
onChange={(e) => update("merchant_id", e.target.value)}
|
|
>
|
|
<option value="">All merchants</option>
|
|
{data.merchants.map((m) => (
|
|
<option value={m.id} key={m.id}>
|
|
{m.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
export function FormActions({
|
|
busy,
|
|
close,
|
|
label = "Save changes",
|
|
}: {
|
|
busy: boolean;
|
|
close: () => void;
|
|
label?: string;
|
|
}) {
|
|
return (
|
|
<div className="form-actions">
|
|
<button
|
|
type="button"
|
|
className="button secondary"
|
|
onClick={close}
|
|
disabled={busy}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button className="button primary" type="submit" disabled={busy}>
|
|
{busy ? "Saving…" : label}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
// Mutate posts a revisioned change and returns the accepted state, so a
|
|
// caller can find ids the server just minted.
|
|
export type Mutate = (
|
|
path: string,
|
|
body: Record<string, unknown>,
|
|
message?: string,
|
|
) => Promise<State>;
|