refactor(ts): BillMerchantRules → TypeScript
Typed MerchantRule/Conflict/Suggestion + generic useDebounce; billId widened to Id (number|string) to match the api layer; asserted billId at the BillModal call site (rendered only for existing bills). Dropped unused RuleChip billId prop. typecheck 0.
This commit is contained in:
parent
4d380c8da0
commit
d4eb48de92
|
|
@ -51,7 +51,7 @@ function StatusChip({ candidate }: { candidate: Candidate }) {
|
||||||
// ── Main dialog ───────────────────────────────────────────────────────────────
|
// ── Main dialog ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface BillHistoricalImportDialogProps {
|
interface BillHistoricalImportDialogProps {
|
||||||
billId: number;
|
billId: number | string;
|
||||||
billName: string;
|
billName: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,47 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, Trash2, X,
|
AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn, fmt } from '@/lib/utils';
|
import { cn, fmt, errMessage } from '@/lib/utils';
|
||||||
|
import { asDollars } from '@/lib/money';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
|
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
|
||||||
|
|
||||||
|
interface MerchantRule {
|
||||||
|
id: number | string;
|
||||||
|
merchant?: string;
|
||||||
|
auto_attribute_late?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Conflict {
|
||||||
|
id: number | string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Suggestion {
|
||||||
|
id: number | string;
|
||||||
|
label?: string;
|
||||||
|
normalized?: string;
|
||||||
|
amount?: number;
|
||||||
|
date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubTx {
|
||||||
|
id: number | string;
|
||||||
|
payee?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
memo?: string | null;
|
||||||
|
merchant?: string | null;
|
||||||
|
amount?: number;
|
||||||
|
posted_date?: string | null;
|
||||||
|
match_status?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// Debounce helper
|
// Debounce helper
|
||||||
function useDebounce(value, delay) {
|
function useDebounce<T>(value: T, delay: number): T {
|
||||||
const [debounced, setDebounced] = useState(value);
|
const [debounced, setDebounced] = useState(value);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setDebounced(value), delay);
|
const t = setTimeout(() => setDebounced(value), delay);
|
||||||
|
|
@ -21,7 +52,13 @@ function useDebounce(value, delay) {
|
||||||
return debounced;
|
return debounced;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RuleChip({ rule, billId, onDelete, onToggleAutoAttr, deleting, togglingAutoAttr }) {
|
function RuleChip({ rule, onDelete, onToggleAutoAttr, deleting, togglingAutoAttr }: {
|
||||||
|
rule: MerchantRule;
|
||||||
|
onDelete: (rule: MerchantRule) => void;
|
||||||
|
onToggleAutoAttr: (rule: MerchantRule, enabled: boolean) => void;
|
||||||
|
deleting: number | string | null;
|
||||||
|
togglingAutoAttr: number | string | null;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
|
@ -65,7 +102,7 @@ function RuleChip({ rule, billId, onDelete, onToggleAutoAttr, deleting, toggling
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConflictWarning({ conflicts }) {
|
function ConflictWarning({ conflicts }: { conflicts?: Conflict[] }) {
|
||||||
if (!conflicts?.length) return null;
|
if (!conflicts?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/8 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
|
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/8 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
|
@ -84,7 +121,7 @@ function ConflictWarning({ conflicts }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PreviewBadge({ count, loading, error }) {
|
function PreviewBadge({ count, loading, error }: { count: number | null; loading: boolean; error: boolean }) {
|
||||||
if (loading) return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />;
|
if (loading) return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />;
|
||||||
if (error) return <span className="rounded-full border border-destructive/30 bg-destructive/10 px-2 py-0.5 text-[10px] font-semibold text-destructive">Error</span>;
|
if (error) return <span className="rounded-full border border-destructive/30 bg-destructive/10 px-2 py-0.5 text-[10px] font-semibold text-destructive">Error</span>;
|
||||||
if (count === null) return null;
|
if (count === null) return null;
|
||||||
|
|
@ -100,24 +137,28 @@ function PreviewBadge({ count, loading, error }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BillMerchantRules({ billId, billName, onRulesChanged }) {
|
export default function BillMerchantRules({ billId, billName, onRulesChanged }: {
|
||||||
const [rules, setRules] = useState([]);
|
billId: number | string;
|
||||||
const [suggestions, setSuggestions] = useState([]);
|
billName?: string;
|
||||||
|
onRulesChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const [rules, setRules] = useState<MerchantRule[]>([]);
|
||||||
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [deleting, setDeleting] = useState(null);
|
const [deleting, setDeleting] = useState<number | string | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
const [previewCount, setPreviewCount] = useState(null);
|
const [previewCount, setPreviewCount] = useState<number | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [previewError, setPreviewError] = useState(false);
|
const [previewError, setPreviewError] = useState(false);
|
||||||
const [conflicts, setConflicts] = useState([]);
|
const [conflicts, setConflicts] = useState<Conflict[]>([]);
|
||||||
const [retroFeedback, setRetroFeedback] = useState(null);
|
const [retroFeedback, setRetroFeedback] = useState<number | null>(null);
|
||||||
const [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
|
const [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
|
||||||
const [togglingAutoAttr, setTogglingAutoAttr] = useState(null);
|
const [togglingAutoAttr, setTogglingAutoAttr] = useState<number | string | null>(null);
|
||||||
const [liveResults, setLiveResults] = useState([]);
|
const [liveResults, setLiveResults] = useState<Suggestion[]>([]);
|
||||||
const [liveSearching, setLiveSearching] = useState(false);
|
const [liveSearching, setLiveSearching] = useState(false);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const debouncedInput = useDebounce(input.trim(), 380);
|
const debouncedInput = useDebounce(input.trim(), 380);
|
||||||
|
|
||||||
|
|
@ -125,7 +166,7 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
if (!billId) return;
|
if (!billId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.billMerchantRules(billId);
|
const data = await api.billMerchantRules(billId) as { rules?: MerchantRule[]; suggestions?: Suggestion[] };
|
||||||
setRules(data.rules || []);
|
setRules(data.rules || []);
|
||||||
setSuggestions(data.suggestions || []);
|
setSuggestions(data.suggestions || []);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -148,9 +189,10 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
setPreviewLoading(true);
|
setPreviewLoading(true);
|
||||||
setPreviewError(false);
|
setPreviewError(false);
|
||||||
api.previewMerchantRule(billId, debouncedInput)
|
api.previewMerchantRule(billId, debouncedInput)
|
||||||
.then(data => {
|
.then(raw => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPreviewCount(data.match_count);
|
const data = raw as { match_count?: number; conflicts?: Conflict[] };
|
||||||
|
setPreviewCount(data.match_count ?? null);
|
||||||
setConflicts(data.conflicts || []);
|
setConflicts(data.conflicts || []);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|
@ -169,9 +211,10 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLiveSearching(true);
|
setLiveSearching(true);
|
||||||
api.subscriptionTransactionMatches({ q: debouncedInput, limit: 20 })
|
api.subscriptionTransactionMatches({ q: debouncedInput, limit: 20 })
|
||||||
.then(data => {
|
.then(raw => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const rows = Array.isArray(data) ? data : (data?.transactions ?? []);
|
const data = raw as SubTx[] | { transactions?: SubTx[] };
|
||||||
|
const rows: SubTx[] = Array.isArray(data) ? data : (data?.transactions ?? []);
|
||||||
setLiveResults(rows.filter(tx => tx.match_status !== 'matched').map(tx => ({
|
setLiveResults(rows.filter(tx => tx.match_status !== 'matched').map(tx => ({
|
||||||
id: tx.id,
|
id: tx.id,
|
||||||
label: tx.payee || tx.description || tx.memo || '',
|
label: tx.payee || tx.description || tx.memo || '',
|
||||||
|
|
@ -187,16 +230,16 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
|
|
||||||
// Popover handles its own outside-click dismissal — no manual handler needed
|
// Popover handles its own outside-click dismissal — no manual handler needed
|
||||||
|
|
||||||
async function handleAdd(merchantText) {
|
async function handleAdd(merchantText?: string) {
|
||||||
const text = (merchantText || input).trim();
|
const text = (merchantText || input).trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
setAdding(true);
|
setAdding(true);
|
||||||
setRetroFeedback(null);
|
setRetroFeedback(null);
|
||||||
try {
|
try {
|
||||||
const result = await api.addMerchantRule(billId, text);
|
const result = await api.addMerchantRule(billId, text) as { rule?: MerchantRule };
|
||||||
setRules(prev => {
|
setRules(prev => {
|
||||||
if (prev.some(r => r.id === result.rule?.id)) return prev;
|
if (prev.some(r => r.id === result.rule?.id)) return prev;
|
||||||
return [...prev, result.rule].filter(Boolean);
|
return [...prev, result.rule].filter(Boolean) as MerchantRule[];
|
||||||
});
|
});
|
||||||
setInput('');
|
setInput('');
|
||||||
setPreviewCount(null);
|
setPreviewCount(null);
|
||||||
|
|
@ -207,13 +250,13 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
setShowHistoricalDialog(true);
|
setShowHistoricalDialog(true);
|
||||||
onRulesChanged?.();
|
onRulesChanged?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to add rule');
|
toast.error(errMessage(err, 'Failed to add rule'));
|
||||||
} finally {
|
} finally {
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(rule) {
|
async function handleDelete(rule: MerchantRule) {
|
||||||
setDeleting(rule.id);
|
setDeleting(rule.id);
|
||||||
try {
|
try {
|
||||||
await api.deleteMerchantRule(billId, rule.id);
|
await api.deleteMerchantRule(billId, rule.id);
|
||||||
|
|
@ -221,13 +264,13 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
toast.success(`Rule "${rule.merchant}" removed`);
|
toast.success(`Rule "${rule.merchant}" removed`);
|
||||||
onRulesChanged?.();
|
onRulesChanged?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to remove rule');
|
toast.error(errMessage(err, 'Failed to remove rule'));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleAutoAttr(rule, enabled) {
|
async function handleToggleAutoAttr(rule: MerchantRule, enabled: boolean) {
|
||||||
setTogglingAutoAttr(rule.id);
|
setTogglingAutoAttr(rule.id);
|
||||||
try {
|
try {
|
||||||
await api.toggleRuleAutoAttribute(billId, rule.id, enabled);
|
await api.toggleRuleAutoAttribute(billId, rule.id, enabled);
|
||||||
|
|
@ -236,14 +279,14 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
? `Auto-fix on — ${rule.merchant} payments will automatically count for the prior month`
|
? `Auto-fix on — ${rule.merchant} payments will automatically count for the prior month`
|
||||||
: 'Auto-fix off');
|
: 'Auto-fix off');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to update rule');
|
toast.error(errMessage(err, 'Failed to update rule'));
|
||||||
} finally {
|
} finally {
|
||||||
setTogglingAutoAttr(null);
|
setTogglingAutoAttr(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickSuggestion(s) {
|
function pickSuggestion(s: Suggestion) {
|
||||||
setInput(s.label);
|
setInput(s.label || '');
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|
@ -272,7 +315,6 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
<RuleChip
|
<RuleChip
|
||||||
key={rule.id}
|
key={rule.id}
|
||||||
rule={rule}
|
rule={rule}
|
||||||
billId={billId}
|
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onToggleAutoAttr={handleToggleAutoAttr}
|
onToggleAutoAttr={handleToggleAutoAttr}
|
||||||
deleting={deleting}
|
deleting={deleting}
|
||||||
|
|
@ -344,7 +386,7 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="min-w-0 flex-1 truncate font-medium">{s.label}</span>
|
<span className="min-w-0 flex-1 truncate font-medium">{s.label}</span>
|
||||||
<span className="shrink-0 font-mono text-muted-foreground tabular-nums">
|
<span className="shrink-0 font-mono text-muted-foreground tabular-nums">
|
||||||
{fmt(amountVal)}
|
{fmt(asDollars(amountVal))}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
@ -65,7 +65,7 @@ export default function LinkedTransactionsSection({
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-3">
|
<div className="px-3 py-3">
|
||||||
<BillMerchantRules
|
<BillMerchantRules
|
||||||
billId={billId}
|
billId={billId!}
|
||||||
billName={billName}
|
billName={billName}
|
||||||
onRulesChanged={onRulesChanged}
|
onRulesChanged={onRulesChanged}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue