refactor(ts): CategoriesPage → TypeScript (category list + drag-reorder + expand)

This commit is contained in:
null 2026-07-04 22:44:39 -05:00
parent 8c9702adbe
commit f15046bce3
1 changed files with 99 additions and 44 deletions

View File

@ -15,30 +15,83 @@ import {
import {
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn, fmt, fmtDate } from '@/lib/utils';
import { cn, fmtDate, errMessage } from '@/lib/utils';
import { formatUSD, asDollars } from '@/lib/money';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
function plural(count, label) {
function fmt(v: number | null | undefined): string {
return formatUSD(asDollars(Number(v) || 0));
}
interface CategoryBill {
id: number;
name: string;
due_day?: number | null;
expected_amount?: number;
total_paid?: number;
payment_count?: number;
last_paid_date?: string | null;
active?: number;
}
interface CatRow {
id: number;
name: string;
active_bill_count?: number;
inactive_bill_count?: number;
payment_count?: number;
bill_names?: string[];
bills?: CategoryBill[];
spending_enabled?: number | boolean;
[key: string]: unknown;
}
interface CategoryMoveControls {
enabled: boolean;
moving: boolean;
canMoveUp: boolean;
canMoveDown: boolean;
onMoveUp: (event: React.MouseEvent) => void;
onMoveDown: (event: React.MouseEvent) => void;
}
interface CategoryDragProps {
draggable: boolean;
isDragging?: boolean;
isDropTarget?: boolean;
onDragStart?: React.DragEventHandler<HTMLElement>;
onDragEnter?: React.DragEventHandler<HTMLElement>;
onDragOver?: React.DragEventHandler<HTMLElement>;
onDragEnd?: React.DragEventHandler<HTMLElement>;
onDrop?: React.DragEventHandler<HTMLElement>;
}
function plural(count: number, label: string): string {
return `${count} ${label}${count === 1 ? '' : 's'}`;
}
function billPreview(names = []) {
function billPreview(names: string[] = []): string {
if (!names.length) return 'No bills in this category yet.';
const visible = names.slice(0, 4).join(', ');
const more = names.length > 4 ? `, +${names.length - 4} more` : '';
return `${visible}${more}`;
}
function categoryBillCount(category) {
function categoryBillCount(category: CatRow): number {
return (category.active_bill_count || 0) + (category.inactive_bill_count || 0);
}
function Chip({ value, label, tone = 'muted', details }) {
const toneClass = {
function Chip({ value, label, tone = 'muted', details }: {
value: React.ReactNode;
label: string;
tone?: string;
details?: string;
}) {
const toneClass = ({
active: 'border-primary/25 bg-primary/10 text-primary',
muted: 'border-border bg-muted/55 text-muted-foreground',
info: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400',
}[tone];
} as Record<string, string>)[tone];
return (
<Tooltip>
@ -64,7 +117,7 @@ function Chip({ value, label, tone = 'muted', details }) {
);
}
function StatChips({ category }) {
function StatChips({ category }: { category: CatRow }) {
const names = billPreview(category.bill_names);
return (
<div className="flex flex-wrap items-center gap-1.5">
@ -90,7 +143,7 @@ function StatChips({ category }) {
}
function ChipLegend() {
const items = [
const items: [string, string][] = [
['Active', 'active'],
['Inactive', 'muted'],
['Payments', 'info'],
@ -113,7 +166,7 @@ function ChipLegend() {
);
}
function StatusPill({ active }) {
function StatusPill({ active }: { active?: number }) {
return (
<span className={cn(
'inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
@ -126,7 +179,7 @@ function StatusPill({ active }) {
);
}
function BillName({ bill }) {
function BillName({ bill }: { bill: CategoryBill }) {
const label = `${bill.name}: due day ${bill.due_day}, ${fmt(bill.expected_amount)} expected`;
return (
<Tooltip>
@ -145,7 +198,7 @@ function BillName({ bill }) {
);
}
function ExpandedBills({ category }) {
function ExpandedBills({ category }: { category: CatRow }) {
const bills = category.bills || [];
if (!bills.length) {
@ -230,30 +283,30 @@ function ExpandedBills({ category }) {
}
export default function CategoriesPage() {
const [categories, setCategories] = useState([]);
const [categories, setCategories] = useState<CatRow[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [newName, setNewName] = useState('');
const [adding, setAdding] = useState(false);
const [expanded, setExpanded] = useState(() => new Set());
const [expanded, setExpanded] = useState<Set<number>>(() => new Set());
const [showEmptyCategories, setShowEmptyCategories] = useState(false);
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const [movingCategoryId, setMovingCategoryId] = useState(null);
const addInputRef = useRef(null);
const [draggingId, setDraggingId] = useState<number | null>(null);
const [dropTargetId, setDropTargetId] = useState<number | null>(null);
const [movingCategoryId, setMovingCategoryId] = useState<number | string | null>(null);
const addInputRef = useRef<HTMLInputElement>(null);
const [renameTarget, setRenameTarget] = useState(null);
const [renameTarget, setRenameTarget] = useState<CatRow | null>(null);
const [renaming, setRenaming] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState<CatRow | null>(null);
const [deleting, setDeleting] = useState(false);
const load = useCallback(async () => {
setLoadError(null);
try {
const cats = await api.categories();
const cats = await api.categories() as CatRow[];
setCategories(cats);
} catch (err) {
setLoadError(err.message || 'Failed to load categories');
setLoadError(errMessage(err, 'Failed to load categories'));
} finally {
setLoading(false);
}
@ -261,7 +314,7 @@ export default function CategoriesPage() {
useEffect(() => { load(); }, [load]);
function toggleCategory(id) {
function toggleCategory(id: number) {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
@ -270,7 +323,7 @@ export default function CategoriesPage() {
});
}
async function handleAdd(e) {
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
const trimmed = newName.trim();
if (!trimmed) {
@ -287,18 +340,19 @@ export default function CategoriesPage() {
addInputRef.current?.focus();
load();
} catch (err) {
toast.error(err.message);
toast.error(errMessage(err, 'Failed to add category'));
} finally {
setAdding(false);
}
}
function openRename(event, cat) {
function openRename(event: React.MouseEvent, cat: CatRow) {
event.stopPropagation();
setRenameTarget(cat);
}
async function handleRename(name) {
async function handleRename(name: string) {
if (!renameTarget) return;
setRenaming(true);
try {
await api.updateCategory(renameTarget.id, { name });
@ -306,22 +360,23 @@ export default function CategoriesPage() {
setRenameTarget(null);
load();
} catch (err) {
toast.error(err.message);
toast.error(errMessage(err, 'Failed to rename category'));
} finally {
setRenaming(false);
}
}
function openDelete(event, cat) {
function openDelete(event: React.MouseEvent, cat: CatRow) {
event.stopPropagation();
setDeleteTarget(cat);
}
async function handleDelete() {
const category = deleteTarget;
if (!category) return;
setDeleting(true);
try {
const category = deleteTarget;
await api.deleteCategory(deleteTarget.id);
await api.deleteCategory(category.id);
toast.success(`"${category.name}" moved to recovery`, {
description: 'Bills keep their category if you restore it within 30 days.',
action: {
@ -332,7 +387,7 @@ export default function CategoriesPage() {
toast.success(`"${category.name}" restored`);
load();
} catch (err) {
toast.error(err.message || 'Failed to restore category');
toast.error(errMessage(err, 'Failed to restore category'));
}
},
},
@ -345,7 +400,7 @@ export default function CategoriesPage() {
setDeleteTarget(null);
load();
} catch (err) {
toast.error(err.message || 'Could not delete category.');
toast.error(errMessage(err, 'Could not delete category.'));
} finally {
setDeleting(false);
}
@ -360,7 +415,7 @@ export default function CategoriesPage() {
: categories.filter(cat => categoryBillCount(cat) > 0);
const reorderEnabled = !loading && !loadError;
async function persistCategoryOrder(nextCategories, movedId) {
async function persistCategoryOrder(nextCategories: CatRow[], movedId: number | string | null) {
setCategories(nextCategories);
setMovingCategoryId(movedId);
try {
@ -368,24 +423,24 @@ export default function CategoriesPage() {
toast.success('Category order saved');
load();
} catch (err) {
toast.error(err.message || 'Failed to save category order');
toast.error(errMessage(err, 'Failed to save category order'));
load();
} finally {
setMovingCategoryId(null);
}
}
function reorderVisibleCategories(orderedVisible) {
function reorderVisibleCategories(orderedVisible: CatRow[]) {
if (!reorderEnabled) return;
const visibleIds = new Set(visibleCategories.map(cat => cat.id));
const replacements = [...orderedVisible];
const nextCategories = categories.map(cat => (
visibleIds.has(cat.id) ? replacements.shift() : cat
visibleIds.has(cat.id) ? replacements.shift()! : cat
));
persistCategoryOrder(nextCategories, movedItemId(visibleCategories, orderedVisible));
}
function categoryMoveControls(cat, index) {
function categoryMoveControls(cat: CatRow, index: number): CategoryMoveControls {
return {
enabled: reorderEnabled,
moving: movingCategoryId === cat.id,
@ -402,7 +457,7 @@ export default function CategoriesPage() {
};
}
function categoryDragProps(cat, index) {
function categoryDragProps(cat: CatRow, index: number): CategoryDragProps {
if (!reorderEnabled) return { draggable: false };
return {
draggable: true,
@ -457,12 +512,12 @@ export default function CategoriesPage() {
<div className="grid gap-3 md:grid-cols-[1fr_minmax(20rem,26rem)]">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{[
{([
['Categories', categories.length],
['Active bills', activeBills],
['Inactive', inactiveBills],
['Payments', paymentCount],
].map(([label, value]) => (
] as [string, number][]).map(([label, value]) => (
<div key={label} className="rounded-xl border border-border/70 bg-card/80 px-4 py-3 shadow-sm">
<p className="text-[11px] font-semibold uppercase tracking-normal text-muted-foreground">{label}</p>
<p className="tracker-number mt-1 text-xl font-bold text-foreground">{value}</p>
@ -652,10 +707,10 @@ export default function CategoriesPage() {
onClick={async (event) => {
event.stopPropagation();
try {
const result = await api.toggleCategorySpending(cat.id, !cat.spending_enabled);
const result = await api.toggleCategorySpending(cat.id, !cat.spending_enabled) as { spending_enabled?: number | boolean };
setCategories(prev => prev.map(c => c.id === cat.id ? { ...c, spending_enabled: result.spending_enabled } : c));
} catch (err) {
toast.error(err.message || 'Failed to update category');
toast.error(errMessage(err, 'Failed to update category'));
}
}}
aria-label={cat.spending_enabled ? `Disable spending for ${cat.name}` : `Enable spending for ${cat.name}`}