feat: historical import batch selection UI, backend endpoint, DB migration v0.83
This commit is contained in:
parent
d5a0b65532
commit
5689fc95c2
|
|
@ -184,7 +184,8 @@ export const api = {
|
|||
previewMerchantRule: (id, merchant) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`),
|
||||
addMerchantRule: (id, merchant) => post(`/bills/${id}/merchant-rules`, { merchant }),
|
||||
deleteMerchantRule: (id, ruleId) => del(`/bills/${id}/merchant-rules/${ruleId}`),
|
||||
merchantRuleCandidates: (id) => get(`/bills/${id}/merchant-rules/candidates`),
|
||||
merchantRuleCandidates: (id) => get(`/bills/${id}/merchant-rules/candidates`),
|
||||
toggleRuleAutoAttribute: (id, ruleId, on) => _fetch('PATCH', `/bills/${id}/merchant-rules/${ruleId}/auto-attribute`, { enabled: on }),
|
||||
importHistoricalPayments: (id, ids) => post(`/bills/${id}/merchant-rules/import-historical`, { transaction_ids: ids }),
|
||||
billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
|
||||
saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertTriangle, Building2, CheckCircle2, Loader2, Plus, Tag, Trash2, X,
|
||||
AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -21,23 +21,47 @@ function useDebounce(value, delay) {
|
|||
return debounced;
|
||||
}
|
||||
|
||||
function RuleChip({ rule, onDelete, deleting }) {
|
||||
function RuleChip({ rule, billId, onDelete, onToggleAutoAttr, deleting, togglingAutoAttr }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-primary/25 bg-primary/8 px-2.5 py-1 text-xs font-medium text-primary">
|
||||
<Tag className="h-3 w-3 shrink-0" />
|
||||
<span className="max-w-[12rem] truncate" title={rule.merchant}>{rule.merchant}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(rule)}
|
||||
disabled={deleting === rule.id}
|
||||
className="ml-0.5 rounded-full p-0.5 opacity-60 transition-opacity hover:opacity-100 disabled:opacity-30"
|
||||
aria-label={`Remove rule "${rule.merchant}"`}
|
||||
>
|
||||
{deleting === rule.id
|
||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: <X className="h-3 w-3" />}
|
||||
</button>
|
||||
</span>
|
||||
<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">
|
||||
<Tag className="h-3 w-3 shrink-0 text-primary" />
|
||||
<span className="text-xs font-medium text-foreground truncate max-w-[10rem]" title={rule.merchant}>
|
||||
{rule.merchant}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* Auto-attribute late payment toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleAutoAttr(rule, !rule.auto_attribute_late)}
|
||||
disabled={togglingAutoAttr === rule.id}
|
||||
title={rule.auto_attribute_late
|
||||
? 'Auto-fixing month crossing is ON — payments in the first 5 days are automatically moved to the prior month'
|
||||
: 'Turn on to automatically move payments that post in the first 5 days of a month to the prior month (e.g. AT&T posts June 1, belongs to May)'}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold transition-colors',
|
||||
rule.auto_attribute_late
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20'
|
||||
: 'border-border/50 bg-background text-muted-foreground hover:bg-muted/40',
|
||||
)}
|
||||
>
|
||||
{togglingAutoAttr === rule.id
|
||||
? <Loader2 className="h-2.5 w-2.5 animate-spin" />
|
||||
: <CalendarDays className="h-2.5 w-2.5" />}
|
||||
{rule.auto_attribute_late ? 'Auto-fix on' : 'Auto-fix'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(rule)}
|
||||
disabled={deleting === rule.id}
|
||||
className="rounded-full p-0.5 text-muted-foreground/60 transition-opacity hover:text-destructive disabled:opacity-30"
|
||||
aria-label={`Remove rule "${rule.merchant}"`}
|
||||
>
|
||||
{deleting === rule.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <X className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -88,8 +112,9 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
const [conflicts, setConflicts] = useState([]);
|
||||
const [retroFeedback, setRetroFeedback] = useState(null);
|
||||
const [retroFeedback, setRetroFeedback] = useState(null);
|
||||
const [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
|
||||
const [togglingAutoAttr, setTogglingAutoAttr] = useState(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const debouncedInput = useDebounce(input.trim(), 380);
|
||||
|
|
@ -175,6 +200,21 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|||
}
|
||||
}
|
||||
|
||||
async function handleToggleAutoAttr(rule, enabled) {
|
||||
setTogglingAutoAttr(rule.id);
|
||||
try {
|
||||
await api.toggleRuleAutoAttribute(billId, rule.id, enabled);
|
||||
setRules(prev => prev.map(r => r.id === rule.id ? { ...r, auto_attribute_late: enabled ? 1 : 0 } : r));
|
||||
toast.success(enabled
|
||||
? `Auto-fix on — ${rule.merchant} payments will automatically count for the prior month`
|
||||
: 'Auto-fix off');
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to update rule');
|
||||
} finally {
|
||||
setTogglingAutoAttr(null);
|
||||
}
|
||||
}
|
||||
|
||||
function pickSuggestion(s) {
|
||||
setInput(s.label);
|
||||
setShowSuggestions(false);
|
||||
|
|
@ -201,13 +241,16 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|||
|
||||
{/* Existing rules */}
|
||||
{rules.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="space-y-1.5">
|
||||
{rules.map(rule => (
|
||||
<RuleChip
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
billId={billId}
|
||||
onDelete={handleDelete}
|
||||
onToggleAutoAttr={handleToggleAutoAttr}
|
||||
deleting={deleting}
|
||||
togglingAutoAttr={togglingAutoAttr}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2705,6 +2705,18 @@ function runMigrations() {
|
|||
).run();
|
||||
console.log(`[v0.82] Normalised ${result.changes} auto_match payment(s) to provider_sync`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.83',
|
||||
description: 'bill_merchant_rules: auto_attribute_late flag for bills that always post after month end',
|
||||
dependsOn: ['v0.82'],
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bill_merchant_rules)').all().map(c => c.name);
|
||||
if (!cols.includes('auto_attribute_late')) {
|
||||
db.exec('ALTER TABLE bill_merchant_rules ADD COLUMN auto_attribute_late INTEGER NOT NULL DEFAULT 0');
|
||||
console.log('[v0.83] bill_merchant_rules.auto_attribute_late added');
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -939,7 +939,7 @@ router.get('/:id/merchant-rules', (req, res) => {
|
|||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||
|
||||
const rules = db.prepare(`
|
||||
SELECT id, merchant, created_at FROM bill_merchant_rules
|
||||
SELECT id, merchant, auto_attribute_late, created_at FROM bill_merchant_rules
|
||||
WHERE user_id = ? AND bill_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`).all(req.user.id, billId);
|
||||
|
|
@ -1183,6 +1183,26 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => {
|
|||
res.json({ imported, late_attributions: lateAttributions });
|
||||
});
|
||||
|
||||
// PATCH /api/bills/:id/merchant-rules/:ruleId/auto-attribute
|
||||
// Toggle the auto_attribute_late flag for a single merchant rule.
|
||||
router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req, res) => {
|
||||
const db = getDb();
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
const ruleId = parseInt(req.params.ruleId, 10);
|
||||
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
|
||||
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
|
||||
if (!requireBill(db, billId, req.user.id))
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||
|
||||
const enabled = req.body?.enabled ? 1 : 0;
|
||||
const changes = db.prepare(
|
||||
"UPDATE bill_merchant_rules SET auto_attribute_late = ? WHERE id = ? AND user_id = ? AND bill_id = ?"
|
||||
).run(enabled, ruleId, req.user.id, billId).changes;
|
||||
|
||||
if (changes === 0) return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND'));
|
||||
res.json({ id: ruleId, auto_attribute_late: enabled === 1 });
|
||||
});
|
||||
|
||||
router.delete('/:id/merchant-rules/:ruleId', (req, res) => {
|
||||
const db = getDb();
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ function applyMerchantRules(db, userId) {
|
|||
let rules;
|
||||
try {
|
||||
rules = db.prepare(`
|
||||
SELECT bmr.bill_id, bmr.merchant, b.name AS bill_name, b.due_day
|
||||
SELECT bmr.bill_id, bmr.merchant, bmr.auto_attribute_late, b.name AS bill_name, b.due_day
|
||||
FROM bill_merchant_rules bmr
|
||||
JOIN bills b ON b.id = bmr.bill_id AND b.user_id = bmr.user_id AND b.deleted_at IS NULL
|
||||
WHERE bmr.user_id = ?
|
||||
|
|
@ -123,18 +123,23 @@ function applyMerchantRules(db, userId) {
|
|||
// Check if this payment just missed the previous month's window
|
||||
const suggestedDate = lateAttributionCandidate(paidDate, rule.due_day);
|
||||
if (suggestedDate) {
|
||||
// Fetch the payment id just inserted
|
||||
const inserted = db.prepare(
|
||||
'SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL'
|
||||
).get(tx.id, rule.bill_id);
|
||||
if (inserted) {
|
||||
lateAttributions.push({
|
||||
payment_id: inserted.id,
|
||||
bill_name: rule.bill_name || `Bill #${rule.bill_id}`,
|
||||
original_date: paidDate,
|
||||
suggested_date: suggestedDate,
|
||||
amount,
|
||||
});
|
||||
if (rule.auto_attribute_late) {
|
||||
// Auto-apply without prompting — this bill always posts after month end
|
||||
db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL")
|
||||
.run(suggestedDate, tx.id, rule.bill_id);
|
||||
} else {
|
||||
const inserted = db.prepare(
|
||||
'SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL'
|
||||
).get(tx.id, rule.bill_id);
|
||||
if (inserted) {
|
||||
lateAttributions.push({
|
||||
payment_id: inserted.id,
|
||||
bill_name: rule.bill_name || `Bill #${rule.bill_id}`,
|
||||
original_date: paidDate,
|
||||
suggested_date: suggestedDate,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue