refactor(ts): CategoriesPage → TypeScript (category list + drag-reorder + expand)
This commit is contained in:
parent
8c9702adbe
commit
f15046bce3
|
|
@ -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}`}
|
||||
Loading…
Reference in New Issue