refactor(ts): CategoriesPage → TypeScript (category list + drag-reorder + expand)
This commit is contained in:
parent
8c9702adbe
commit
f15046bce3
|
|
@ -15,30 +15,83 @@ import {
|
||||||
import {
|
import {
|
||||||
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
|
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} 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';
|
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'}`;
|
return `${count} ${label}${count === 1 ? '' : 's'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function billPreview(names = []) {
|
function billPreview(names: string[] = []): string {
|
||||||
if (!names.length) return 'No bills in this category yet.';
|
if (!names.length) return 'No bills in this category yet.';
|
||||||
const visible = names.slice(0, 4).join(', ');
|
const visible = names.slice(0, 4).join(', ');
|
||||||
const more = names.length > 4 ? `, +${names.length - 4} more` : '';
|
const more = names.length > 4 ? `, +${names.length - 4} more` : '';
|
||||||
return `${visible}${more}`;
|
return `${visible}${more}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryBillCount(category) {
|
function categoryBillCount(category: CatRow): number {
|
||||||
return (category.active_bill_count || 0) + (category.inactive_bill_count || 0);
|
return (category.active_bill_count || 0) + (category.inactive_bill_count || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Chip({ value, label, tone = 'muted', details }) {
|
function Chip({ value, label, tone = 'muted', details }: {
|
||||||
const toneClass = {
|
value: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
tone?: string;
|
||||||
|
details?: string;
|
||||||
|
}) {
|
||||||
|
const toneClass = ({
|
||||||
active: 'border-primary/25 bg-primary/10 text-primary',
|
active: 'border-primary/25 bg-primary/10 text-primary',
|
||||||
muted: 'border-border bg-muted/55 text-muted-foreground',
|
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',
|
info: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400',
|
||||||
}[tone];
|
} as Record<string, string>)[tone];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<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);
|
const names = billPreview(category.bill_names);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
|
@ -90,7 +143,7 @@ function StatChips({ category }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChipLegend() {
|
function ChipLegend() {
|
||||||
const items = [
|
const items: [string, string][] = [
|
||||||
['Active', 'active'],
|
['Active', 'active'],
|
||||||
['Inactive', 'muted'],
|
['Inactive', 'muted'],
|
||||||
['Payments', 'info'],
|
['Payments', 'info'],
|
||||||
|
|
@ -113,7 +166,7 @@ function ChipLegend() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusPill({ active }) {
|
function StatusPill({ active }: { active?: number }) {
|
||||||
return (
|
return (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
'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`;
|
const label = `${bill.name}: due day ${bill.due_day}, ${fmt(bill.expected_amount)} expected`;
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
@ -145,7 +198,7 @@ function BillName({ bill }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExpandedBills({ category }) {
|
function ExpandedBills({ category }: { category: CatRow }) {
|
||||||
const bills = category.bills || [];
|
const bills = category.bills || [];
|
||||||
|
|
||||||
if (!bills.length) {
|
if (!bills.length) {
|
||||||
|
|
@ -230,30 +283,30 @@ function ExpandedBills({ category }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState<CatRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [adding, setAdding] = useState(false);
|
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 [showEmptyCategories, setShowEmptyCategories] = useState(false);
|
||||||
const [draggingId, setDraggingId] = useState(null);
|
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||||
const [dropTargetId, setDropTargetId] = useState(null);
|
const [dropTargetId, setDropTargetId] = useState<number | null>(null);
|
||||||
const [movingCategoryId, setMovingCategoryId] = useState(null);
|
const [movingCategoryId, setMovingCategoryId] = useState<number | string | null>(null);
|
||||||
const addInputRef = useRef(null);
|
const addInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [renameTarget, setRenameTarget] = useState(null);
|
const [renameTarget, setRenameTarget] = useState<CatRow | null>(null);
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState<CatRow | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
try {
|
try {
|
||||||
const cats = await api.categories();
|
const cats = await api.categories() as CatRow[];
|
||||||
setCategories(cats);
|
setCategories(cats);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLoadError(err.message || 'Failed to load categories');
|
setLoadError(errMessage(err, 'Failed to load categories'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -261,7 +314,7 @@ export default function CategoriesPage() {
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
function toggleCategory(id) {
|
function toggleCategory(id: number) {
|
||||||
setExpanded(prev => {
|
setExpanded(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) next.delete(id);
|
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();
|
e.preventDefault();
|
||||||
const trimmed = newName.trim();
|
const trimmed = newName.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|
@ -287,18 +340,19 @@ export default function CategoriesPage() {
|
||||||
addInputRef.current?.focus();
|
addInputRef.current?.focus();
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message);
|
toast.error(errMessage(err, 'Failed to add category'));
|
||||||
} finally {
|
} finally {
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRename(event, cat) {
|
function openRename(event: React.MouseEvent, cat: CatRow) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setRenameTarget(cat);
|
setRenameTarget(cat);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRename(name) {
|
async function handleRename(name: string) {
|
||||||
|
if (!renameTarget) return;
|
||||||
setRenaming(true);
|
setRenaming(true);
|
||||||
try {
|
try {
|
||||||
await api.updateCategory(renameTarget.id, { name });
|
await api.updateCategory(renameTarget.id, { name });
|
||||||
|
|
@ -306,22 +360,23 @@ export default function CategoriesPage() {
|
||||||
setRenameTarget(null);
|
setRenameTarget(null);
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message);
|
toast.error(errMessage(err, 'Failed to rename category'));
|
||||||
} finally {
|
} finally {
|
||||||
setRenaming(false);
|
setRenaming(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDelete(event, cat) {
|
function openDelete(event: React.MouseEvent, cat: CatRow) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setDeleteTarget(cat);
|
setDeleteTarget(cat);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
|
const category = deleteTarget;
|
||||||
|
if (!category) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
const category = deleteTarget;
|
await api.deleteCategory(category.id);
|
||||||
await api.deleteCategory(deleteTarget.id);
|
|
||||||
toast.success(`"${category.name}" moved to recovery`, {
|
toast.success(`"${category.name}" moved to recovery`, {
|
||||||
description: 'Bills keep their category if you restore it within 30 days.',
|
description: 'Bills keep their category if you restore it within 30 days.',
|
||||||
action: {
|
action: {
|
||||||
|
|
@ -332,7 +387,7 @@ export default function CategoriesPage() {
|
||||||
toast.success(`"${category.name}" restored`);
|
toast.success(`"${category.name}" restored`);
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} 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);
|
setDeleteTarget(null);
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Could not delete category.');
|
toast.error(errMessage(err, 'Could not delete category.'));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -360,7 +415,7 @@ export default function CategoriesPage() {
|
||||||
: categories.filter(cat => categoryBillCount(cat) > 0);
|
: categories.filter(cat => categoryBillCount(cat) > 0);
|
||||||
const reorderEnabled = !loading && !loadError;
|
const reorderEnabled = !loading && !loadError;
|
||||||
|
|
||||||
async function persistCategoryOrder(nextCategories, movedId) {
|
async function persistCategoryOrder(nextCategories: CatRow[], movedId: number | string | null) {
|
||||||
setCategories(nextCategories);
|
setCategories(nextCategories);
|
||||||
setMovingCategoryId(movedId);
|
setMovingCategoryId(movedId);
|
||||||
try {
|
try {
|
||||||
|
|
@ -368,24 +423,24 @@ export default function CategoriesPage() {
|
||||||
toast.success('Category order saved');
|
toast.success('Category order saved');
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to save category order');
|
toast.error(errMessage(err, 'Failed to save category order'));
|
||||||
load();
|
load();
|
||||||
} finally {
|
} finally {
|
||||||
setMovingCategoryId(null);
|
setMovingCategoryId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reorderVisibleCategories(orderedVisible) {
|
function reorderVisibleCategories(orderedVisible: CatRow[]) {
|
||||||
if (!reorderEnabled) return;
|
if (!reorderEnabled) return;
|
||||||
const visibleIds = new Set(visibleCategories.map(cat => cat.id));
|
const visibleIds = new Set(visibleCategories.map(cat => cat.id));
|
||||||
const replacements = [...orderedVisible];
|
const replacements = [...orderedVisible];
|
||||||
const nextCategories = categories.map(cat => (
|
const nextCategories = categories.map(cat => (
|
||||||
visibleIds.has(cat.id) ? replacements.shift() : cat
|
visibleIds.has(cat.id) ? replacements.shift()! : cat
|
||||||
));
|
));
|
||||||
persistCategoryOrder(nextCategories, movedItemId(visibleCategories, orderedVisible));
|
persistCategoryOrder(nextCategories, movedItemId(visibleCategories, orderedVisible));
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryMoveControls(cat, index) {
|
function categoryMoveControls(cat: CatRow, index: number): CategoryMoveControls {
|
||||||
return {
|
return {
|
||||||
enabled: reorderEnabled,
|
enabled: reorderEnabled,
|
||||||
moving: movingCategoryId === cat.id,
|
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 };
|
if (!reorderEnabled) return { draggable: false };
|
||||||
return {
|
return {
|
||||||
draggable: true,
|
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 gap-3 md:grid-cols-[1fr_minmax(20rem,26rem)]">
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
{[
|
{([
|
||||||
['Categories', categories.length],
|
['Categories', categories.length],
|
||||||
['Active bills', activeBills],
|
['Active bills', activeBills],
|
||||||
['Inactive', inactiveBills],
|
['Inactive', inactiveBills],
|
||||||
['Payments', paymentCount],
|
['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">
|
<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="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>
|
<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) => {
|
onClick={async (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
try {
|
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));
|
setCategories(prev => prev.map(c => c.id === cat.id ? { ...c, spending_enabled: result.spending_enabled } : c));
|
||||||
} catch (err) {
|
} 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}`}
|
aria-label={cat.spending_enabled ? `Disable spending for ${cat.name}` : `Enable spending for ${cat.name}`}
|
||||||
Loading…
Reference in New Issue