init
This commit is contained in:
+278
@@ -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>;
|
||||
Reference in New Issue
Block a user