This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
import { useEffect, useId, useRef } from "react";
import type { ReactNode } from "react";
import { X, Inbox, AlertCircle } from "lucide-react";
import type { Dataset, Filter } from "./api";
import { categoryPath, emptyFilter } 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>
);
}
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 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>
);
}
export function TagPicker({
data,
value,
onChange,
}: {
data: Dataset;
value: string[];
onChange: (ids: string[]) => void;
}) {
return (
<fieldset className="tag-picker">
<legend>Tags</legend>
{data.tags.length ? (
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>
))
) : (
<small>No tags yet. Create them in Tags.</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>
))}
</>
);
}
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();
return (
<div className="filters">
<Field label="From">
<input
type="date"
value={value.from}
max={value.to || undefined}
onChange={(e) => update("from", e.target.value)}
/>
</Field>
<Field label="To">
<input
type="date"
value={value.to}
min={value.from || undefined}
onChange={(e) => update("to", e.target.value)}
/>
</Field>
<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>
<button
className="button subtle filter-reset"
onClick={() => onChange({ ...emptyFilter })}
>
Reset
</button>
</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>
);
}
export type Mutate = (
path: string,
body: Record<string, unknown>,
message?: string,
) => Promise<void>;