This commit is contained in:
null 2026-05-15 02:26:10 -05:00
parent 48dcb480ba
commit 153ed7ab79
2 changed files with 101 additions and 26 deletions

View File

@ -1100,9 +1100,23 @@ function BillDetailView({ group, onBack, onImport, isImporting, importResult })
</button>
<span className="text-sm font-medium truncate">{bill.name}</span>
{importResult ? (
<span className="text-xs text-emerald-500 font-medium shrink-0">
{importResult.created + importResult.updated} imported
<div className="text-right shrink-0">
<span className={`text-xs font-medium ${
importResult.created + importResult.updated === 0 ? 'text-amber-400' : 'text-emerald-500'
}`}>
{importResult.created + importResult.updated > 0
? `${importResult.created + importResult.updated} imported`
: `⚠ already existed`}
{importResult.duplicates > 0 && importResult.created + importResult.updated > 0
&& ` · ${importResult.duplicates} dupes`}
</span>
{importResult.duplicates > 0 && importResult.earliestDup && (
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
{importResult.duplicates} dup{importResult.duplicates > 1 ? 's' : ''} · recorded{' '}
{importResult.earliestDup.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</p>
)}
</div>
) : (
<Button size="sm" onClick={onImport} disabled={isImporting} className="h-7 text-xs px-3 shrink-0 gap-1.5">
{isImporting && <Loader2 className="h-3 w-3 animate-spin" />}
@ -1180,13 +1194,27 @@ function BillHistoryView({ previewRows, allBills, importingBillId, billImportRes
{counts.high > 0 && <span className="text-[10px] text-emerald-500">{counts.high} high</span>}
{counts.medium > 0 && <span className="text-[10px] text-amber-500">{counts.medium} med</span>}
{counts.low > 0 && <span className="text-[10px] text-muted-foreground/50">{counts.low} low</span>}
{importResult && (
<span className="text-[10px] text-emerald-500 font-medium">
{importResult.created + importResult.updated} imported
{importResult && (() => {
const imported = importResult.created + importResult.updated;
const allDupes = imported === 0 && importResult.duplicates > 0;
const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
const dupDate = importResult.earliestDup ? ` · ${fmtDate(importResult.earliestDup)}` : '';
return (
<div className="space-y-0.5">
<span className={`text-[10px] font-medium ${allDupes ? 'text-amber-400' : 'text-emerald-500'}`}>
{imported > 0 ? `${imported} imported` : '⚠ already existed'}
{importResult.duplicates > 0 && imported > 0 && ` · ${importResult.duplicates} dupes`}
{importResult.errored > 0 && ` · ${importResult.errored} errors`}
</span>
{importResult.duplicates > 0 && (
<p className="text-[10px] text-muted-foreground/70">
{importResult.duplicates} duplicate{importResult.duplicates > 1 ? 's' : ''}{dupDate}
</p>
)}
</div>
);
})()}
</div>
<div className="mt-1.5 space-y-0.5">
{sorted3.map(row => (
<div key={row.row_id} className="flex items-center gap-2 text-xs text-muted-foreground">
@ -1323,9 +1351,42 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
const created = result.rows_created ?? 0;
const updated = result.rows_updated ?? 0;
const errored = result.rows_errored ?? 0;
const duplicates = result.rows_duplicates ?? 0;
setBillImportResults(prev => new Map(prev).set(group.bill.id, { created, updated, errored }));
toast.success(`Imported ${created + updated} entr${created + updated === 1 ? 'y' : 'ies'} for "${group.bill.name}"`);
// Collect created_at dates from duplicate detail entries so we can show
// when the existing payments were originally recorded.
const dupDates = (result.details ?? [])
.filter(d => d.result === 'skipped_duplicate' && d.existing_created_at)
.map(d => new Date(d.existing_created_at))
.filter(d => !isNaN(d.getTime()))
.sort((a, b) => a - b);
const earliestDup = dupDates[0] ?? null;
const latestDup = dupDates.at(-1) ?? null;
setBillImportResults(prev => new Map(prev).set(group.bill.id, {
created, updated, errored, duplicates, earliestDup, latestDup,
}));
const imported = created + updated;
const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
if (imported === 0 && duplicates > 0) {
const dateHint = earliestDup
? ` (first recorded ${fmtDate(earliestDup)})`
: '';
toast.warning(
`All ${duplicates} row${duplicates > 1 ? 's' : ''} for "${group.bill.name}" already exist${dateHint} — nothing new imported.`,
);
} else {
const parts = [`${imported} entr${imported === 1 ? 'y' : 'ies'} imported`];
if (duplicates > 0) {
const dateHint = earliestDup ? ` (recorded ${fmtDate(earliestDup)})` : '';
parts.push(`${duplicates} already existed${dateHint}`);
}
if (errored > 0) parts.push(`${errored} error${errored > 1 ? 's' : ''}`);
toast.success(`${group.bill.name}${parts.join(' · ')}`);
}
onHistoryRefresh?.();
} catch (err) {
toast.error(err.message || `Import failed for "${group.bill.name}"`);

View File

@ -1487,6 +1487,7 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
if (existing && !allowOverwrite) {
summary.skipped++;
summary.duplicates++;
summary.details.push({ row_id, action, result: 'skipped_duplicate', note: `Bill "${name}" already exists (id=${existing.id})` });
return;
}
@ -1610,13 +1611,22 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
}
const dup = db.prepare(`
SELECT id FROM payments
SELECT id, created_at, paid_date, amount FROM payments
WHERE bill_id = ? AND paid_date = ? AND amount = ? AND deleted_at IS NULL
`).get(billId, payDate, payAmount);
if (dup && !allowOverwrite) {
summary.skipped++;
summary.details.push({ row_id, action, result: 'skipped_duplicate', note: 'Identical payment already exists' });
summary.duplicates++;
summary.details.push({
row_id,
action,
result: 'skipped_duplicate',
note: 'Identical payment already exists',
existing_created_at: dup.created_at ?? null,
existing_paid_date: dup.paid_date ?? null,
existing_amount: dup.amount ?? null,
});
return;
}
@ -1653,7 +1663,7 @@ async function applyImportDecisions(userId, importSessionId, decisions, opts = {
const reviewedSkippedCount = Number.isInteger(Number(opts.reviewed_skipped_count))
? Math.max(0, Number(opts.reviewed_skipped_count))
: 0;
const summary = { created: 0, updated: 0, skipped: reviewedSkippedCount, errored: 0, ambiguous: 0, details: [] };
const summary = { created: 0, updated: 0, skipped: reviewedSkippedCount, duplicates: 0, errored: 0, ambiguous: 0, details: [] };
const applyAll = db.transaction(() => {
for (const decision of decisions) {
@ -1688,7 +1698,10 @@ async function applyImportDecisions(userId, importSessionId, decisions, opts = {
console.error('[import] history write failed:', histErr.message);
}
deleteImportSession(db, importSessionId);
// Session is intentionally kept alive until its 24-hour TTL expires.
// Deleting it here would prevent the user from importing additional bills
// from the same file in a second apply call (Bills tab workflow).
// pruneExpiredSessions() handles cleanup on the next preview call.
return {
success: true,
@ -1698,6 +1711,7 @@ async function applyImportDecisions(userId, importSessionId, decisions, opts = {
rows_skipped: summary.skipped,
rows_ambiguous: summary.ambiguous,
rows_errored: summary.errored,
rows_duplicates: summary.duplicates ?? 0,
details: summary.details,
};
}