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)}`),
|
previewMerchantRule: (id, merchant) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`),
|
||||||
addMerchantRule: (id, merchant) => post(`/bills/${id}/merchant-rules`, { merchant }),
|
addMerchantRule: (id, merchant) => post(`/bills/${id}/merchant-rules`, { merchant }),
|
||||||
deleteMerchantRule: (id, ruleId) => del(`/bills/${id}/merchant-rules/${ruleId}`),
|
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 }),
|
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}`),
|
billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
|
||||||
saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data),
|
saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
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, Loader2, Plus, Tag, Trash2, X,
|
AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, Trash2, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -21,23 +21,47 @@ function useDebounce(value, delay) {
|
||||||
return debounced;
|
return debounced;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RuleChip({ rule, onDelete, deleting }) {
|
function RuleChip({ rule, billId, onDelete, onToggleAutoAttr, deleting, togglingAutoAttr }) {
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||||
<Tag className="h-3 w-3 shrink-0" />
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<span className="max-w-[12rem] truncate" title={rule.merchant}>{rule.merchant}</span>
|
<Tag className="h-3 w-3 shrink-0 text-primary" />
|
||||||
<button
|
<span className="text-xs font-medium text-foreground truncate max-w-[10rem]" title={rule.merchant}>
|
||||||
type="button"
|
{rule.merchant}
|
||||||
onClick={() => onDelete(rule)}
|
</span>
|
||||||
disabled={deleting === rule.id}
|
</div>
|
||||||
className="ml-0.5 rounded-full p-0.5 opacity-60 transition-opacity hover:opacity-100 disabled:opacity-30"
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
aria-label={`Remove rule "${rule.merchant}"`}
|
{/* Auto-attribute late payment toggle */}
|
||||||
>
|
<button
|
||||||
{deleting === rule.id
|
type="button"
|
||||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
onClick={() => onToggleAutoAttr(rule, !rule.auto_attribute_late)}
|
||||||
: <X className="h-3 w-3" />}
|
disabled={togglingAutoAttr === rule.id}
|
||||||
</button>
|
title={rule.auto_attribute_late
|
||||||
</span>
|
? '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 [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [previewError, setPreviewError] = useState(false);
|
const [previewError, setPreviewError] = useState(false);
|
||||||
const [conflicts, setConflicts] = useState([]);
|
const [conflicts, setConflicts] = useState([]);
|
||||||
const [retroFeedback, setRetroFeedback] = useState(null);
|
const [retroFeedback, setRetroFeedback] = useState(null);
|
||||||
const [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
|
const [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
|
||||||
|
const [togglingAutoAttr, setTogglingAutoAttr] = useState(null);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
const debouncedInput = useDebounce(input.trim(), 380);
|
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) {
|
function pickSuggestion(s) {
|
||||||
setInput(s.label);
|
setInput(s.label);
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
|
|
@ -201,13 +241,16 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
|
|
||||||
{/* Existing rules */}
|
{/* Existing rules */}
|
||||||
{rules.length > 0 && (
|
{rules.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="space-y-1.5">
|
||||||
{rules.map(rule => (
|
{rules.map(rule => (
|
||||||
<RuleChip
|
<RuleChip
|
||||||
key={rule.id}
|
key={rule.id}
|
||||||
rule={rule}
|
rule={rule}
|
||||||
|
billId={billId}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onToggleAutoAttr={handleToggleAutoAttr}
|
||||||
deleting={deleting}
|
deleting={deleting}
|
||||||
|
togglingAutoAttr={togglingAutoAttr}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2705,6 +2705,18 @@ function runMigrations() {
|
||||||
).run();
|
).run();
|
||||||
console.log(`[v0.82] Normalised ${result.changes} auto_match payment(s) to provider_sync`);
|
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'));
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||||
|
|
||||||
const rules = db.prepare(`
|
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 = ?
|
WHERE user_id = ? AND bill_id = ?
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
`).all(req.user.id, billId);
|
`).all(req.user.id, billId);
|
||||||
|
|
@ -1183,6 +1183,26 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => {
|
||||||
res.json({ imported, late_attributions: lateAttributions });
|
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) => {
|
router.delete('/:id/merchant-rules/:ruleId', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const billId = parseInt(req.params.id, 10);
|
const billId = parseInt(req.params.id, 10);
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ function applyMerchantRules(db, userId) {
|
||||||
let rules;
|
let rules;
|
||||||
try {
|
try {
|
||||||
rules = db.prepare(`
|
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
|
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
|
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 = ?
|
WHERE bmr.user_id = ?
|
||||||
|
|
@ -123,18 +123,23 @@ function applyMerchantRules(db, userId) {
|
||||||
// Check if this payment just missed the previous month's window
|
// Check if this payment just missed the previous month's window
|
||||||
const suggestedDate = lateAttributionCandidate(paidDate, rule.due_day);
|
const suggestedDate = lateAttributionCandidate(paidDate, rule.due_day);
|
||||||
if (suggestedDate) {
|
if (suggestedDate) {
|
||||||
// Fetch the payment id just inserted
|
if (rule.auto_attribute_late) {
|
||||||
const inserted = db.prepare(
|
// Auto-apply without prompting — this bill always posts after month end
|
||||||
'SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL'
|
db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL")
|
||||||
).get(tx.id, rule.bill_id);
|
.run(suggestedDate, tx.id, rule.bill_id);
|
||||||
if (inserted) {
|
} else {
|
||||||
lateAttributions.push({
|
const inserted = db.prepare(
|
||||||
payment_id: inserted.id,
|
'SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL'
|
||||||
bill_name: rule.bill_name || `Bill #${rule.bill_id}`,
|
).get(tx.id, rule.bill_id);
|
||||||
original_date: paidDate,
|
if (inserted) {
|
||||||
suggested_date: suggestedDate,
|
lateAttributions.push({
|
||||||
amount,
|
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