v0.27.04
This commit is contained in:
parent
48dcb480ba
commit
153ed7ab79
|
|
@ -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}"`);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue