v0.27.04
This commit is contained in:
parent
48dcb480ba
commit
153ed7ab79
|
|
@ -1100,9 +1100,23 @@ function BillDetailView({ group, onBack, onImport, isImporting, importResult })
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm font-medium truncate">{bill.name}</span>
|
<span className="text-sm font-medium truncate">{bill.name}</span>
|
||||||
{importResult ? (
|
{importResult ? (
|
||||||
<span className="text-xs text-emerald-500 font-medium shrink-0">
|
<div className="text-right shrink-0">
|
||||||
✓ {importResult.created + importResult.updated} imported
|
<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>
|
</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">
|
<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" />}
|
{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.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.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>}
|
{counts.low > 0 && <span className="text-[10px] text-muted-foreground/50">{counts.low} low</span>}
|
||||||
{importResult && (
|
{importResult && (() => {
|
||||||
<span className="text-[10px] text-emerald-500 font-medium">
|
const imported = importResult.created + importResult.updated;
|
||||||
✓ {importResult.created + importResult.updated} imported
|
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`}
|
{importResult.errored > 0 && ` · ${importResult.errored} errors`}
|
||||||
</span>
|
</span>
|
||||||
|
{importResult.duplicates > 0 && (
|
||||||
|
<p className="text-[10px] text-muted-foreground/70">
|
||||||
|
{importResult.duplicates} duplicate{importResult.duplicates > 1 ? 's' : ''}{dupDate}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
<div className="mt-1.5 space-y-0.5">
|
<div className="mt-1.5 space-y-0.5">
|
||||||
{sorted3.map(row => (
|
{sorted3.map(row => (
|
||||||
<div key={row.row_id} className="flex items-center gap-2 text-xs text-muted-foreground">
|
<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 created = result.rows_created ?? 0;
|
||||||
const updated = result.rows_updated ?? 0;
|
const updated = result.rows_updated ?? 0;
|
||||||
const errored = result.rows_errored ?? 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 }));
|
// Collect created_at dates from duplicate detail entries so we can show
|
||||||
toast.success(`Imported ${created + updated} entr${created + updated === 1 ? 'y' : 'ies'} for "${group.bill.name}"`);
|
// 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?.();
|
onHistoryRefresh?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || `Import failed for "${group.bill.name}"`);
|
toast.error(err.message || `Import failed for "${group.bill.name}"`);
|
||||||
|
|
|
||||||
|
|
@ -1487,6 +1487,7 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
||||||
|
|
||||||
if (existing && !allowOverwrite) {
|
if (existing && !allowOverwrite) {
|
||||||
summary.skipped++;
|
summary.skipped++;
|
||||||
|
summary.duplicates++;
|
||||||
summary.details.push({ row_id, action, result: 'skipped_duplicate', note: `Bill "${name}" already exists (id=${existing.id})` });
|
summary.details.push({ row_id, action, result: 'skipped_duplicate', note: `Bill "${name}" already exists (id=${existing.id})` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1610,13 +1611,22 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
||||||
}
|
}
|
||||||
|
|
||||||
const dup = db.prepare(`
|
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
|
WHERE bill_id = ? AND paid_date = ? AND amount = ? AND deleted_at IS NULL
|
||||||
`).get(billId, payDate, payAmount);
|
`).get(billId, payDate, payAmount);
|
||||||
|
|
||||||
if (dup && !allowOverwrite) {
|
if (dup && !allowOverwrite) {
|
||||||
summary.skipped++;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1653,7 +1663,7 @@ async function applyImportDecisions(userId, importSessionId, decisions, opts = {
|
||||||
const reviewedSkippedCount = Number.isInteger(Number(opts.reviewed_skipped_count))
|
const reviewedSkippedCount = Number.isInteger(Number(opts.reviewed_skipped_count))
|
||||||
? Math.max(0, Number(opts.reviewed_skipped_count))
|
? Math.max(0, Number(opts.reviewed_skipped_count))
|
||||||
: 0;
|
: 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(() => {
|
const applyAll = db.transaction(() => {
|
||||||
for (const decision of decisions) {
|
for (const decision of decisions) {
|
||||||
|
|
@ -1688,7 +1698,10 @@ async function applyImportDecisions(userId, importSessionId, decisions, opts = {
|
||||||
console.error('[import] history write failed:', histErr.message);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -1698,6 +1711,7 @@ async function applyImportDecisions(userId, importSessionId, decisions, opts = {
|
||||||
rows_skipped: summary.skipped,
|
rows_skipped: summary.skipped,
|
||||||
rows_ambiguous: summary.ambiguous,
|
rows_ambiguous: summary.ambiguous,
|
||||||
rows_errored: summary.errored,
|
rows_errored: summary.errored,
|
||||||
|
rows_duplicates: summary.duplicates ?? 0,
|
||||||
details: summary.details,
|
details: summary.details,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue