removed unneccesary files
This commit is contained in:
@@ -1,552 +0,0 @@
|
||||
// web/src/pages/settings/CategoriesPage.tsx
|
||||
import { useMemo, useState, useEffect, type FormEvent } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import { Money } from "../../components/ui";
|
||||
import SettingsNav from "./_SettingsNav";
|
||||
import {
|
||||
useCategories,
|
||||
useCreateCategory,
|
||||
useUpdateCategory,
|
||||
useDeleteCategory,
|
||||
} from "../../hooks/useCategories";
|
||||
import { useToast } from "../../components/Toast";
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
name: string;
|
||||
percent: number;
|
||||
priority: number;
|
||||
isSavings: boolean;
|
||||
balanceCents: number;
|
||||
};
|
||||
|
||||
function SumBadge({ total }: { total: number }) {
|
||||
const tone =
|
||||
total === 100
|
||||
? "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40"
|
||||
: total < 100
|
||||
? "bg-amber-500/10 text-amber-100 border border-amber-500/30"
|
||||
: "bg-red-500/10 text-red-100 border border-red-500/40";
|
||||
const label = total === 100 ? "Perfect" : total < 100 ? "Under" : "Over";
|
||||
return (
|
||||
<div className={`badge ${tone}`}>
|
||||
{label}: {total}%
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsCategoriesPage() {
|
||||
const { data, isLoading, error, refetch, isFetching } = useDashboard();
|
||||
const cats = useCategories() as Row[];
|
||||
const createM = useCreateCategory();
|
||||
const updateM = useUpdateCategory();
|
||||
const deleteM = useDeleteCategory();
|
||||
const { push } = useToast();
|
||||
const normalizeName = (value: string) => value.trim().toLowerCase();
|
||||
const MIN_SAVINGS_PERCENT = 20;
|
||||
|
||||
const total = useMemo(
|
||||
() => cats.reduce((s, c) => s + c.percent, 0),
|
||||
[cats],
|
||||
);
|
||||
const savingsTotal = useMemo(
|
||||
() => cats.reduce((sum, c) => sum + (c.isSavings ? c.percent : 0), 0),
|
||||
[cats]
|
||||
);
|
||||
const remainingPercent = Math.max(0, 100 - total);
|
||||
|
||||
// Drag ordering state (initially from priority)
|
||||
const [order, setOrder] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
const sorted = cats
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.priority - b.priority || a.name.localeCompare(b.name),
|
||||
);
|
||||
const next = sorted.map((c) => c.id);
|
||||
// Reset order when cats change in length or ids
|
||||
if (
|
||||
order.length !== next.length ||
|
||||
next.some((id, i) => order[i] !== id)
|
||||
) {
|
||||
setOrder(next);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cats.map((c) => c.id).join("|")]);
|
||||
|
||||
// Add form state
|
||||
const [name, setName] = useState("");
|
||||
const [percent, setPercent] = useState("");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [isSavings, setIsSavings] = useState(false);
|
||||
|
||||
const parsedPercent = Math.max(0, Math.floor(Number(percent) || 0));
|
||||
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
|
||||
const addDisabled =
|
||||
!name.trim() ||
|
||||
parsedPercent <= 0 ||
|
||||
parsedPercent > 100 ||
|
||||
parsedPercent > remainingPercent ||
|
||||
createM.isPending;
|
||||
|
||||
const onAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const normalizedName = normalizeName(name);
|
||||
if (cats.some((c) => normalizeName(c.name) === normalizedName)) {
|
||||
push("err", `Expense name '${normalizedName}' already exists`);
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
name: normalizedName,
|
||||
percent: parsedPercent,
|
||||
priority: parsedPriority,
|
||||
isSavings,
|
||||
};
|
||||
if (!body.name) return;
|
||||
if (body.percent > remainingPercent) {
|
||||
push("err", `Only ${remainingPercent}% is available right now.`);
|
||||
return;
|
||||
}
|
||||
const nextTotal = total + body.percent;
|
||||
const nextSavingsTotal = savingsTotal + (body.isSavings ? body.percent : 0);
|
||||
if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) {
|
||||
push(
|
||||
"err",
|
||||
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
createM.mutate(body, {
|
||||
onSuccess: () => {
|
||||
push("ok", "Expense created");
|
||||
setName("");
|
||||
setPercent("");
|
||||
setPriority("");
|
||||
setIsSavings(false);
|
||||
},
|
||||
onError: (err: any) =>
|
||||
push("err", err?.message ?? "Create failed"),
|
||||
});
|
||||
};
|
||||
|
||||
const onEdit = (id: string, patch: Partial<Row>) => {
|
||||
if (patch.name !== undefined) {
|
||||
const normalizedName = normalizeName(patch.name);
|
||||
if (
|
||||
cats.some((c) => c.id !== id && normalizeName(c.name) === normalizedName)
|
||||
) {
|
||||
push("err", `Expense name '${normalizedName}' already exists`);
|
||||
return;
|
||||
}
|
||||
patch.name = normalizedName;
|
||||
}
|
||||
if (patch.percent !== undefined) {
|
||||
const current = cats.find((c) => c.id === id);
|
||||
if (!current) return;
|
||||
const sanitized = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.floor(patch.percent)),
|
||||
);
|
||||
const nextTotal = total - current.percent + sanitized;
|
||||
if (nextTotal > 100) {
|
||||
push("err", `Updating this would push totals to ${nextTotal}%.`);
|
||||
return;
|
||||
}
|
||||
patch.percent = sanitized;
|
||||
}
|
||||
if (patch.priority !== undefined) {
|
||||
patch.priority = Math.max(0, Math.floor(patch.priority));
|
||||
}
|
||||
if (patch.isSavings !== undefined || patch.percent !== undefined) {
|
||||
const current = cats.find((c) => c.id === id);
|
||||
if (!current) return;
|
||||
const nextPercent = patch.percent ?? current.percent;
|
||||
const wasSavings = current.isSavings ? current.percent : 0;
|
||||
const nextIsSavings = patch.isSavings ?? current.isSavings;
|
||||
const nextSavings = nextIsSavings ? nextPercent : 0;
|
||||
const nextTotal =
|
||||
total - current.percent + (patch.percent ?? current.percent);
|
||||
const nextSavingsTotal =
|
||||
savingsTotal - wasSavings + nextSavings;
|
||||
if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) {
|
||||
push(
|
||||
"err",
|
||||
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
updateM.mutate(
|
||||
{ id, body: patch },
|
||||
{
|
||||
onError: (err: any) =>
|
||||
push("err", err?.message ?? "Update failed"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
deleteM.mutate(id, {
|
||||
onSuccess: () => push("ok", "Expense deleted"),
|
||||
onError: (err: any) =>
|
||||
push("err", err?.message ?? "Delete failed"),
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
setOrder((prev) => {
|
||||
const oldIndex = prev.indexOf(String(active.id));
|
||||
const newIndex = prev.indexOf(String(over.id));
|
||||
const next = arrayMove(prev, oldIndex, newIndex);
|
||||
// Apply new priorities to server (only changed ones)
|
||||
const updates = onDragOrderApply(next);
|
||||
updates.forEach(({ id, priority }) => {
|
||||
const existing = cats.find((c) => c.id === id);
|
||||
if (existing && existing.priority !== priority) {
|
||||
updateM.mutate({ id, body: { priority } });
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="card max-w-2xl">
|
||||
<SettingsNav />
|
||||
<div className="muted">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="card max-w-2xl">
|
||||
<SettingsNav />
|
||||
<p className="mb-3">Couldn't load expenses.</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 max-w-5xl">
|
||||
<section className="card">
|
||||
<SettingsNav />
|
||||
|
||||
<header className="mb-4 space-y-1">
|
||||
<h1 className="text-lg font-semibold">Expenses</h1>
|
||||
<p className="text-sm muted">
|
||||
Decide how every dollar is divided. Percentages must always
|
||||
add up to 100%.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Add form */}
|
||||
<form
|
||||
onSubmit={onAdd}
|
||||
className="row gap-2 mb-4 flex-wrap items-end"
|
||||
>
|
||||
<input
|
||||
className="input w-full sm:w-44"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-28"
|
||||
placeholder="%"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={percent}
|
||||
onChange={(e) => setPercent(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-28"
|
||||
placeholder="Priority"
|
||||
type="number"
|
||||
min={0}
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
<label className="row w-full sm:w-auto">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSavings}
|
||||
onChange={(e) => setIsSavings(e.target.checked)}
|
||||
/>
|
||||
<span className="muted text-sm">Savings</span>
|
||||
</label>
|
||||
<button
|
||||
className="btn w-full sm:w-auto"
|
||||
disabled={addDisabled || createM.isPending}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<div className="ml-auto text-right">
|
||||
<SumBadge total={total} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{cats.length === 0 ? (
|
||||
<div className="muted text-sm mt-4">
|
||||
No expenses yet.
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<table className="table mt-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>%</th>
|
||||
<th>Priority</th>
|
||||
<th>Savings</th>
|
||||
<th>Balance</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<SortableContext
|
||||
items={order}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody>
|
||||
{order
|
||||
.map((id) => cats.find((c) => c.id === id))
|
||||
.filter(Boolean)
|
||||
.map((c) => (
|
||||
<SortableTr key={c!.id} id={c!.id}>
|
||||
<td className="px-3 py-2">
|
||||
<span className="drag-handle inline-flex items-center justify-center w-6 h-6 rounded-full bg-[--color-panel] text-lg cursor-grab active:cursor-grabbing">
|
||||
⋮⋮
|
||||
</span>
|
||||
</td>
|
||||
<td className="rounded-l-[--radius-xl] px-3 py-2">
|
||||
<InlineEditText
|
||||
value={c!.name}
|
||||
onChange={(v) =>
|
||||
onEdit(c!.id, { name: v })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditNumber
|
||||
value={c!.percent}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(v) =>
|
||||
onEdit(c!.id, { percent: v })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditNumber
|
||||
value={c!.priority}
|
||||
min={0}
|
||||
onChange={(v) =>
|
||||
onEdit(c!.id, { priority: v })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditCheckbox
|
||||
checked={c!.isSavings}
|
||||
onChange={(v) =>
|
||||
onEdit(c!.id, { isSavings: v })
|
||||
}
|
||||
/>
|
||||
{c!.isSavings && (
|
||||
<span className="badge ml-2 text-[10px] bg-emerald-500/10 text-emerald-200 border border-emerald-500/40">
|
||||
Savings
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Money cents={c!.balanceCents ?? 0} />
|
||||
</td>
|
||||
<td className="rounded-r-[--radius-xl] px-3 py-2">
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => onDelete(c!.id)}
|
||||
disabled={deleteM.isPending}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</SortableTr>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* Guard if total != 100 */}
|
||||
{total !== 100 && (
|
||||
<div className="toast-err mt-3">
|
||||
Percents must sum to <strong>100%</strong> for allocations.
|
||||
Current total: {total}%.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- tiny inline editors --- */
|
||||
function InlineEditText({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [v, setV] = useState(value);
|
||||
const [editing, setEditing] = useState(false);
|
||||
useEffect(() => setV(value), [value]);
|
||||
const commit = () => {
|
||||
if (v !== value) onChange(v.trim());
|
||||
setEditing(false);
|
||||
};
|
||||
return editing ? (
|
||||
<input
|
||||
className="input"
|
||||
value={v}
|
||||
onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="link inline-flex items-center gap-1"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<span>{value}</span>
|
||||
<span className="text-[10px] opacity-60">Edit</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditNumber({
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max = Number.MAX_SAFE_INTEGER,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState(String(value));
|
||||
useEffect(() => setV(String(value)), [value]);
|
||||
const commit = () => {
|
||||
const n = Math.max(
|
||||
min,
|
||||
Math.min(max, Math.floor(Number(v) || 0)),
|
||||
);
|
||||
if (n !== value) onChange(n);
|
||||
setEditing(false);
|
||||
};
|
||||
return editing ? (
|
||||
<input
|
||||
className="input w-24 text-right"
|
||||
value={v}
|
||||
onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditCheckbox({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className="muted text-sm">
|
||||
{checked ? "Yes" : "No"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableTr({
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
} as React.CSSProperties;
|
||||
return (
|
||||
<tr
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="sortable-row"
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function onDragOrderApply(ids: string[]) {
|
||||
return ids.map((id, idx) => ({ id, priority: idx + 1 }));
|
||||
}
|
||||
@@ -1,747 +0,0 @@
|
||||
// web/src/pages/settings/PlansPage.tsx
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
useEffect,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import SettingsNav from "./_SettingsNav";
|
||||
import {
|
||||
useCreatePlan,
|
||||
useUpdatePlan,
|
||||
useDeletePlan,
|
||||
} from "../../hooks/useFixedPlans";
|
||||
import { Money } from "../../components/ui";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import { getTodayInTimezone, dateStringToUTCMidnight, isoToDateString, getBrowserTimezone, formatDateInTimezone } from "../../utils/timezone";
|
||||
|
||||
function daysUntil(iso: string, userTimezone: string) {
|
||||
const today = getTodayInTimezone(userTimezone);
|
||||
const due = isoToDateString(iso, userTimezone);
|
||||
const todayDate = new Date(today);
|
||||
const dueDate = new Date(due);
|
||||
const diffMs = dueDate.getTime() - todayDate.getTime();
|
||||
return Math.round(diffMs / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
function DueBadge({ dueISO, userTimezone }: { dueISO: string; userTimezone: string }) {
|
||||
const d = daysUntil(dueISO, userTimezone);
|
||||
if (d < 0)
|
||||
return (
|
||||
<span
|
||||
className="badge"
|
||||
style={{ borderColor: "#7f1d1d" }}
|
||||
>
|
||||
Overdue
|
||||
</span>
|
||||
);
|
||||
if (d <= 7) return <span className="badge">Due in {d}d</span>;
|
||||
return (
|
||||
<span className="badge" aria-hidden="true">
|
||||
On track
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPlansPage() {
|
||||
const { data, isLoading, error, refetch, isFetching } = useDashboard();
|
||||
const createM = useCreatePlan();
|
||||
const updateM = useUpdatePlan();
|
||||
const deleteM = useDeletePlan();
|
||||
const { push } = useToast();
|
||||
|
||||
// Get user timezone from dashboard data
|
||||
const userTimezone = data?.user?.timezone || getBrowserTimezone();
|
||||
|
||||
// Add form state
|
||||
const [name, setName] = useState("");
|
||||
const [total, setTotal] = useState("");
|
||||
const [funded, setFunded] = useState("");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
|
||||
|
||||
// Auto-payment form state
|
||||
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
|
||||
const [frequency, setFrequency] = useState<"weekly" | "biweekly" | "monthly" | "daily" | "custom">("monthly");
|
||||
const [dayOfMonth, setDayOfMonth] = useState(1);
|
||||
const [dayOfWeek, setDayOfWeek] = useState(0);
|
||||
const [everyNDays, setEveryNDays] = useState(30);
|
||||
const [minFundingPercent, setMinFundingPercent] = useState(100);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
if (!data) return { funded: 0, total: 0, remaining: 0 };
|
||||
const funded = data.fixedPlans.reduce(
|
||||
(s, p) => s + p.fundedCents,
|
||||
0,
|
||||
);
|
||||
const total = data.fixedPlans.reduce(
|
||||
(s, p) => s + p.totalCents,
|
||||
0,
|
||||
);
|
||||
return {
|
||||
funded,
|
||||
total,
|
||||
remaining: Math.max(0, total - funded),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const overallPctFunded = useMemo(() => {
|
||||
if (!totals.total) return 0;
|
||||
return Math.round((totals.funded / totals.total) * 100);
|
||||
}, [totals.funded, totals.total]);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="card max-w-3xl">
|
||||
<SettingsNav />
|
||||
<div className="muted">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="card max-w-3xl">
|
||||
<SettingsNav />
|
||||
<p className="mb-3">Couldn't load fixed expenses.</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const totalCents = Math.max(
|
||||
0,
|
||||
Math.round((parseFloat(total || "0")) * 100),
|
||||
);
|
||||
const fundedCents = Math.max(
|
||||
0,
|
||||
Math.round((parseFloat(funded || "0")) * 100),
|
||||
);
|
||||
const paymentSchedule = autoPayEnabled ? {
|
||||
frequency,
|
||||
...(frequency === "monthly" ? { dayOfMonth } : {}),
|
||||
...(frequency === "weekly" || frequency === "biweekly" ? { dayOfWeek } : {}),
|
||||
...(frequency === "custom" ? { everyNDays } : {}),
|
||||
minFundingPercent,
|
||||
} : undefined;
|
||||
|
||||
const body = {
|
||||
name: name.trim(),
|
||||
totalCents,
|
||||
fundedCents: Math.min(fundedCents, totalCents),
|
||||
priority: Math.max(
|
||||
0,
|
||||
Math.floor(Number(priority) || 0),
|
||||
),
|
||||
dueOn: dateStringToUTCMidnight(due, userTimezone),
|
||||
autoPayEnabled,
|
||||
paymentSchedule,
|
||||
};
|
||||
if (!body.name || totalCents <= 0) return;
|
||||
createM.mutate(body, {
|
||||
onSuccess: () => {
|
||||
push("ok", "Plan created");
|
||||
setName("");
|
||||
setTotal("");
|
||||
setFunded("");
|
||||
setPriority("");
|
||||
setDue(getTodayInTimezone(userTimezone));
|
||||
setAutoPayEnabled(false);
|
||||
setFrequency("monthly");
|
||||
setDayOfMonth(1);
|
||||
setDayOfWeek(0);
|
||||
setEveryNDays(30);
|
||||
setMinFundingPercent(100);
|
||||
},
|
||||
onError: (err: any) =>
|
||||
push("err", err?.message ?? "Create failed"),
|
||||
});
|
||||
};
|
||||
|
||||
const onEdit = (
|
||||
id: string,
|
||||
patch: Partial<{
|
||||
name: string;
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
priority: number;
|
||||
dueOn: string;
|
||||
}>,
|
||||
) => {
|
||||
if (
|
||||
"totalCents" in patch &&
|
||||
"fundedCents" in patch &&
|
||||
(patch.totalCents ?? 0) < (patch.fundedCents ?? 0)
|
||||
) {
|
||||
patch.fundedCents = patch.totalCents;
|
||||
}
|
||||
updateM.mutate(
|
||||
{ id, body: patch },
|
||||
{
|
||||
onSuccess: () => push("ok", "Plan updated"),
|
||||
onError: (err: any) =>
|
||||
push("err", err?.message ?? "Update failed"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
deleteM.mutate(id, {
|
||||
onSuccess: () => push("ok", "Plan deleted"),
|
||||
onError: (err: any) =>
|
||||
push("err", err?.message ?? "Delete failed"),
|
||||
});
|
||||
};
|
||||
|
||||
const addDisabled =
|
||||
!name || !total || createM.isPending;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 max-w-5xl">
|
||||
<section className="card">
|
||||
<SettingsNav />
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-4 space-y-1">
|
||||
<h1 className="text-lg font-semibold">
|
||||
Fixed expenses
|
||||
</h1>
|
||||
<p className="text-sm muted">
|
||||
Long-term goals and obligations you’re funding over
|
||||
time.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* KPI strip */}
|
||||
<div className="grid gap-2 sm:grid-cols-4 mb-4">
|
||||
<KpiCard label="Funded">
|
||||
<Money cents={totals.funded} />
|
||||
</KpiCard>
|
||||
<KpiCard label="Total">
|
||||
<Money cents={totals.total} />
|
||||
</KpiCard>
|
||||
<KpiCard label="Remaining">
|
||||
<Money cents={totals.remaining} />
|
||||
</KpiCard>
|
||||
<KpiCard label="Overall progress">
|
||||
<span className="text-xl font-semibold">
|
||||
{overallPctFunded}%
|
||||
</span>
|
||||
</KpiCard>
|
||||
</div>
|
||||
|
||||
{/* Overall progress bar */}
|
||||
<div className="mb-4 space-y-1">
|
||||
<div className="row text-xs muted">
|
||||
<span>All fixed expenses funded</span>
|
||||
<span className="ml-auto">
|
||||
{overallPctFunded}% of target
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-[--color-panel] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[--color-accent]"
|
||||
style={{
|
||||
width: `${Math.min(100, overallPctFunded)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
<form
|
||||
onSubmit={onAdd}
|
||||
className="row gap-2 mb-4 flex-wrap items-end"
|
||||
>
|
||||
<input
|
||||
className="input w-full sm:w-48"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-28"
|
||||
placeholder="Total $"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={total}
|
||||
onChange={(e) => setTotal(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-28"
|
||||
placeholder="Funded $"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={funded}
|
||||
onChange={(e) => setFunded(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-24"
|
||||
placeholder="Priority"
|
||||
type="number"
|
||||
min={0}
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-40"
|
||||
type="date"
|
||||
value={due}
|
||||
onChange={(e) => setDue(e.target.value)}
|
||||
/>
|
||||
<label className="row gap-2 items-center text-sm cursor-pointer px-3 py-2 rounded-lg bg-[--color-panel] w-full sm:w-auto">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoPayEnabled}
|
||||
onChange={(e) => setAutoPayEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>Auto-fund</span>
|
||||
</label>
|
||||
<button className="btn w-full sm:w-auto" disabled={addDisabled}>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Auto-payment configuration */}
|
||||
{autoPayEnabled && (
|
||||
<div className="card bg-[--color-panel] p-4 mb-4">
|
||||
<h4 className="section-title text-sm mb-3">Auto-Fund Schedule</h4>
|
||||
<div className="row gap-4 flex-wrap items-end">
|
||||
<label className="stack text-sm">
|
||||
<span className="muted text-xs">Frequency</span>
|
||||
<select
|
||||
className="input w-32"
|
||||
value={frequency}
|
||||
onChange={(e) => setFrequency(e.target.value as any)}
|
||||
>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="biweekly">Biweekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{(frequency === "weekly" || frequency === "biweekly") && (
|
||||
<label className="stack text-sm">
|
||||
<span className="muted text-xs">Day of Week</span>
|
||||
<select
|
||||
className="input w-32"
|
||||
value={dayOfWeek}
|
||||
onChange={(e) => setDayOfWeek(Number(e.target.value))}
|
||||
>
|
||||
<option value={0}>Sunday</option>
|
||||
<option value={1}>Monday</option>
|
||||
<option value={2}>Tuesday</option>
|
||||
<option value={3}>Wednesday</option>
|
||||
<option value={4}>Thursday</option>
|
||||
<option value={5}>Friday</option>
|
||||
<option value={6}>Saturday</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{frequency === "monthly" && (
|
||||
<label className="stack text-sm">
|
||||
<span className="muted text-xs">Day of Month</span>
|
||||
<input
|
||||
className="input w-24"
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
value={dayOfMonth}
|
||||
onChange={(e) => setDayOfMonth(Number(e.target.value) || 1)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{frequency === "custom" && (
|
||||
<label className="stack text-sm">
|
||||
<span className="muted text-xs">Every N Days</span>
|
||||
<input
|
||||
className="input w-24"
|
||||
type="number"
|
||||
min="1"
|
||||
value={everyNDays}
|
||||
onChange={(e) => setEveryNDays(Number(e.target.value) || 30)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="stack text-sm">
|
||||
<span className="muted text-xs">Min. Funding %</span>
|
||||
<input
|
||||
className="input w-24"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={minFundingPercent}
|
||||
onChange={(e) => setMinFundingPercent(Math.max(0, Math.min(100, Number(e.target.value) || 0)))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs muted mt-2">
|
||||
Automatic payments will only occur if the expense is funded to at least the minimum percentage.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{data.fixedPlans.length === 0 ? (
|
||||
<div className="muted text-sm">
|
||||
No fixed expenses yet.
|
||||
</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Due</th>
|
||||
<th>Priority</th>
|
||||
<th>Funded</th>
|
||||
<th>Total</th>
|
||||
<th>Remaining</th>
|
||||
<th>Status</th>
|
||||
<th>Auto-Pay</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.fixedPlans
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.priority - b.priority ||
|
||||
new Date(a.dueOn).getTime() -
|
||||
new Date(b.dueOn).getTime(),
|
||||
)
|
||||
.map((p) => {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
p.totalCents - p.fundedCents,
|
||||
);
|
||||
const pctFunded = p.totalCents
|
||||
? Math.round(
|
||||
(p.fundedCents / p.totalCents) * 100,
|
||||
)
|
||||
: 0;
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td className="rounded-l-[--radius-xl] px-3 py-2">
|
||||
<InlineEditText
|
||||
value={p.name}
|
||||
onChange={(v) =>
|
||||
onEdit(p.id, { name: v })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditDate
|
||||
value={p.dueOn}
|
||||
timezone={userTimezone}
|
||||
onChange={(iso) =>
|
||||
onEdit(p.id, { dueOn: iso })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditNumber
|
||||
value={p.priority}
|
||||
min={0}
|
||||
onChange={(n) =>
|
||||
onEdit(p.id, { priority: n })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="space-y-1">
|
||||
<InlineEditMoney
|
||||
valueCents={p.fundedCents}
|
||||
onChange={(cents) =>
|
||||
onEdit(p.id, {
|
||||
fundedCents: Math.max(
|
||||
0,
|
||||
Math.min(cents, p.totalCents),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="row text-xs muted">
|
||||
<span>{pctFunded}% funded</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="space-y-1">
|
||||
<InlineEditMoney
|
||||
valueCents={p.totalCents}
|
||||
onChange={(cents) =>
|
||||
onEdit(p.id, {
|
||||
totalCents: Math.max(cents, 0),
|
||||
fundedCents: Math.min(
|
||||
p.fundedCents,
|
||||
cents,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FundingBar
|
||||
pct={pctFunded}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Money cents={remaining} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<DueBadge dueISO={p.dueOn} userTimezone={userTimezone} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
p.autoPayEnabled
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{p.autoPayEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
{p.autoPayEnabled && p.paymentSchedule && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{p.paymentSchedule.frequency === 'custom'
|
||||
? `Every ${p.paymentSchedule.customDays} days`
|
||||
: p.paymentSchedule.frequency.charAt(0).toUpperCase() + p.paymentSchedule.frequency.slice(1)
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="rounded-r-[--radius-xl] px-3 py-2">
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => onDelete(p.id)}
|
||||
disabled={deleteM.isPending}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Small presentational helpers --- */
|
||||
|
||||
function KpiCard({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="card kpi">
|
||||
<h3>{label}</h3>
|
||||
<div className="val">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FundingBar({ pct }: { pct: number }) {
|
||||
const clamped = Math.min(100, Math.max(0, pct));
|
||||
return (
|
||||
<div className="h-1.5 w-full rounded-full bg-[--color-panel] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[--color-accent]"
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Inline editors (same behavior, slightly nicer UX) --- */
|
||||
|
||||
function InlineEditText({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState(value);
|
||||
useEffect(() => setV(value), [value]);
|
||||
const commit = () => {
|
||||
const t = v.trim();
|
||||
if (t && t !== value) onChange(t);
|
||||
setEditing(false);
|
||||
};
|
||||
return editing ? (
|
||||
<input
|
||||
className="input"
|
||||
value={v}
|
||||
onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="link inline-flex items-center gap-1"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<span>{value}</span>
|
||||
<span className="text-[10px] opacity-60">Edit</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditNumber({
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max = Number.MAX_SAFE_INTEGER,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState(String(value));
|
||||
useEffect(() => setV(String(value)), [value]);
|
||||
const commit = () => {
|
||||
const n = Math.max(
|
||||
min,
|
||||
Math.min(max, Math.floor(Number(v) || 0)),
|
||||
);
|
||||
if (n !== value) onChange(n);
|
||||
setEditing(false);
|
||||
};
|
||||
return editing ? (
|
||||
<input
|
||||
className="input w-24 text-right"
|
||||
value={v}
|
||||
onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditMoney({
|
||||
valueCents,
|
||||
onChange,
|
||||
}: {
|
||||
valueCents: number;
|
||||
onChange: (cents: number) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState((valueCents / 100).toFixed(2));
|
||||
useEffect(
|
||||
() => setV((valueCents / 100).toFixed(2)),
|
||||
[valueCents],
|
||||
);
|
||||
const commit = () => {
|
||||
const cents = Math.max(
|
||||
0,
|
||||
Math.round((parseFloat(v || "0")) * 100),
|
||||
);
|
||||
if (cents !== valueCents) onChange(cents);
|
||||
setEditing(false);
|
||||
};
|
||||
return editing ? (
|
||||
<input
|
||||
className="input w-28 text-right font-mono"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
value={v}
|
||||
onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="link font-mono"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{(valueCents / 100).toFixed(2)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditDate({
|
||||
value,
|
||||
onChange,
|
||||
timezone,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (iso: string) => void;
|
||||
timezone: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState(
|
||||
isoToDateString(value, timezone),
|
||||
);
|
||||
useEffect(
|
||||
() =>
|
||||
setV(
|
||||
isoToDateString(value, timezone),
|
||||
),
|
||||
[value, timezone],
|
||||
);
|
||||
const commit = () => {
|
||||
const iso = dateStringToUTCMidnight(v, timezone);
|
||||
if (iso !== value) onChange(iso);
|
||||
setEditing(false);
|
||||
};
|
||||
return editing ? (
|
||||
<input
|
||||
className="input w-40"
|
||||
type="date"
|
||||
value={v}
|
||||
onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{formatDateInTimezone(value, timezone)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
export default function SettingsNav() {
|
||||
const link = (to: string, label: string) =>
|
||||
<NavLink to={to} className={({isActive}) => `link ${isActive ? "link-active" : ""}`}>{label}</NavLink>;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mb-3 sm:flex-row sm:items-center">
|
||||
<h2 className="section-title m-0">Settings</h2>
|
||||
<div className="flex flex-wrap gap-1 sm:ml-auto">
|
||||
{link("/settings/categories", "Expenses")}
|
||||
{link("/settings/plans", "Fixed Expenses")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
:root {
|
||||
--color-bg: #0b0c10;
|
||||
--color-panel: #111318;
|
||||
--color-fg: #e7e9ee;
|
||||
--color-ink: #2a2e37;
|
||||
--color-accent: #5dd6b2;
|
||||
--radius-xl: 12px;
|
||||
--radius-lg: 10px;
|
||||
--radius-md: 8px;
|
||||
--shadow-1: 0 6px 20px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; }
|
||||
body { margin: 0; background: var(--color-bg); color: var(--color-fg); font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.container { width: min(1100px, 100vww); margin-inline: auto; padding: 0 16px; }
|
||||
.muted { opacity: 0.7; }
|
||||
|
||||
.card {
|
||||
background: var(--color-panel);
|
||||
border: 1px solid var(--color-ink);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.row { display: flex; align-items: center; }
|
||||
.stack { display: grid; gap: 12px; }
|
||||
|
||||
.section-title { font-weight: 700; font-size: 16px; margin-bottom: 10px; }
|
||||
|
||||
.input {
|
||||
background: #0f1116;
|
||||
color: var(--color-fg);
|
||||
border: 1px solid var(--color-ink);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
.input:focus { border-color: var(--color-accent); }
|
||||
|
||||
.btn {
|
||||
background: var(--color-accent);
|
||||
color: #062016;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn[disabled] { opacity: 0.5; cursor: default; }
|
||||
.badge {
|
||||
background: var(--color-ink);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.table { width: 100%; border-collapse: separate; border-spacing: 0 8px; }
|
||||
.table thead th {
|
||||
text-align: left; font-size: 12px; opacity: 0.7; padding: 0 8px;
|
||||
}
|
||||
.table tbody tr {
|
||||
background: var(--color-panel);
|
||||
border: 1px solid var(--color-ink);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
.table td { padding: 8px; }
|
||||
|
||||
.toast-err {
|
||||
background: #b21d2a;
|
||||
color: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.border { border: 1px solid var(--color-ink); }
|
||||
.rounded-xl { border-radius: var(--radius-xl); }
|
||||
.divide-y > * + * { border-top: 1px solid var(--color-ink); }
|
||||
|
||||
/* utility-ish */
|
||||
.w-44 { width: 11rem; }
|
||||
.w-56 { width: 14rem; }
|
||||
.w-40 { width: 10rem; }
|
||||
.ml-auto { margin-left: auto; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-3 { margin-top: 0.75rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.text-sm { font-size: 0.875rem; }
|
||||
.text-xl { font-size: 1.25rem; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
|
||||
.bg-\[--color-ink\] { background: var(--color-ink); }
|
||||
.bg-\[--color-ink\]\/60 { background: color-mix(in oklab, var(--color-ink), transparent 40%); }
|
||||
.bg-\[--color-panel\] { background: var(--color-panel); }
|
||||
.text-\[--color-fg\] { color: var(--color-fg); }
|
||||
.border-\[--color-ink\] { border-color: var(--color-ink); }
|
||||
.rounded-\[--radius-xl\] { border-radius: var(--radius-xl); }
|
||||
.p-2 { padding: 0.5rem; }
|
||||
.p-3 { padding: 0.75rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-6 { padding: 1.5rem; }
|
||||
.py-8 { padding-block: 2rem; }
|
||||
.h-14 { height: 3.5rem; }
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
.opacity-70 { opacity: .7; }
|
||||
.grid { display: grid; }
|
||||
.md\:grid-cols-2 { grid-template-columns: 1fr; }
|
||||
.md\:grid-cols-3 { grid-template-columns: 1fr; }
|
||||
@media (min-width: 768px) {
|
||||
.md\:grid-cols-2 { grid-template-columns: 1fr 1fr; }
|
||||
.md\:grid-cols-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
}
|
||||
.shadow-sm { box-shadow: 0 2px 12px rgba(0,0,0,0.2); }
|
||||
.underline { text-decoration: underline; }
|
||||
.fixed { position: fixed; }
|
||||
.bottom-4 { bottom: 1rem; }
|
||||
.left-1\/2 { left: 50%; }
|
||||
.-translate-x-1\/2 { transform: translateX(-50%); }
|
||||
.z-50 { z-index: 50; }
|
||||
.space-y-2 > * + * { margin-top: .5rem; }
|
||||
.space-y-8 > * + * { margin-top: 2rem; }
|
||||
.space-y-6 > * + * { margin-top: 1.5rem; }
|
||||
.overflow-x-auto { overflow-x: auto; }
|
||||
Reference in New Issue
Block a user