fix: handle duplicate file hash on re-import and improve alert colors

- importedFileService now upserts: if file hash already exists for a
  source (e.g. from a previous failed import), it updates the existing
  record instead of hitting the UNIQUE constraint.
- Replaced Tailwind amber/red/emerald colors with the app's CSS
  variables (--negative, --positive, --accent) for proper contrast
  on the cream background theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-08 22:27:49 +00:00
parent 764fdad6db
commit 273a17c0ac
5 changed files with 36 additions and 23 deletions

View file

@ -25,13 +25,13 @@ export default function DuplicateCheckPanel({
{/* File-level duplicate */} {/* File-level duplicate */}
{result.fileAlreadyImported && ( {result.fileAlreadyImported && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800"> <div className="flex items-start gap-3 p-4 rounded-xl bg-[var(--card)] border-2 border-[var(--accent)]">
<FileWarning size={20} className="text-amber-500 shrink-0 mt-0.5" /> <FileWarning size={20} className="text-[var(--accent)] shrink-0 mt-0.5" />
<div> <div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-200"> <p className="text-sm font-medium text-[var(--foreground)]">
{t("import.duplicates.fileAlreadyImported")} {t("import.duplicates.fileAlreadyImported")}
</p> </p>
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1"> <p className="text-xs text-[var(--muted-foreground)] mt-1">
{t("import.duplicates.fileAlreadyImportedDesc")} {t("import.duplicates.fileAlreadyImportedDesc")}
</p> </p>
</div> </div>
@ -41,18 +41,18 @@ export default function DuplicateCheckPanel({
{/* Row-level duplicates */} {/* Row-level duplicates */}
{result.duplicateRows.length > 0 ? ( {result.duplicateRows.length > 0 ? (
<div> <div>
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 mb-4"> <div className="flex items-start gap-3 p-4 rounded-xl bg-[var(--card)] border-2 border-[var(--accent)] mb-4">
<AlertTriangle <AlertTriangle
size={20} size={20}
className="text-amber-500 shrink-0 mt-0.5" className="text-[var(--accent)] shrink-0 mt-0.5"
/> />
<div> <div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-200"> <p className="text-sm font-medium text-[var(--foreground)]">
{t("import.duplicates.rowsFound", { {t("import.duplicates.rowsFound", {
count: result.duplicateRows.length, count: result.duplicateRows.length,
})} })}
</p> </p>
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1"> <p className="text-xs text-[var(--muted-foreground)] mt-1">
{t("import.duplicates.rowsFoundDesc")} {t("import.duplicates.rowsFoundDesc")}
</p> </p>
</div> </div>
@ -105,7 +105,7 @@ export default function DuplicateCheckPanel({
{result.duplicateRows.map((row) => ( {result.duplicateRows.map((row) => (
<tr <tr
key={row.rowIndex} key={row.rowIndex}
className="bg-amber-50/50 dark:bg-amber-950/10" className="bg-[var(--muted)]"
> >
<td className="px-3 py-2 text-[var(--muted-foreground)]"> <td className="px-3 py-2 text-[var(--muted-foreground)]">
{row.rowIndex + 1} {row.rowIndex + 1}
@ -125,9 +125,9 @@ export default function DuplicateCheckPanel({
</div> </div>
) : ( ) : (
!result.fileAlreadyImported && ( !result.fileAlreadyImported && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-50 dark:bg-emerald-950/20 border border-emerald-200 dark:border-emerald-800"> <div className="flex items-center gap-3 p-4 rounded-xl bg-[var(--card)] border-2 border-[var(--positive)]">
<CheckCircle size={20} className="text-emerald-500" /> <CheckCircle size={20} className="text-[var(--positive)]" />
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200"> <p className="text-sm font-medium text-[var(--foreground)]">
{t("import.duplicates.noneFound")} {t("import.duplicates.noneFound")}
</p> </p>
</div> </div>

View file

@ -32,7 +32,7 @@ export default function FilePreviewTable({
{t("import.preview.rowCount", { count: rows.length })} {t("import.preview.rowCount", { count: rows.length })}
</span> </span>
{errorCount > 0 && ( {errorCount > 0 && (
<span className="flex items-center gap-1 text-red-500"> <span className="flex items-center gap-1 text-[var(--negative)]">
<AlertCircle size={14} /> <AlertCircle size={14} />
{t("import.preview.errorCount", { count: errorCount })} {t("import.preview.errorCount", { count: errorCount })}
</span> </span>
@ -67,7 +67,7 @@ export default function FilePreviewTable({
key={row.rowIndex} key={row.rowIndex}
className={ className={
row.error row.error
? "bg-red-50 dark:bg-red-950/20" ? "bg-[color-mix(in_srgb,var(--negative)_10%,var(--card))]"
: "hover:bg-[var(--muted)]" : "hover:bg-[var(--muted)]"
} }
> >
@ -76,7 +76,7 @@ export default function FilePreviewTable({
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{row.parsed?.date || ( {row.parsed?.date || (
<span className="text-red-500 text-xs"> <span className="text-[var(--negative)] text-xs">
{row.error || "—"} {row.error || "—"}
</span> </span>
)} )}

View file

@ -30,19 +30,19 @@ export default function ImportReportPanel({
icon: CheckCircle, icon: CheckCircle,
label: t("import.report.imported"), label: t("import.report.imported"),
value: report.importedCount, value: report.importedCount,
color: "text-emerald-500", color: "text-[var(--positive)]",
}, },
{ {
icon: AlertTriangle, icon: AlertTriangle,
label: t("import.report.skippedDuplicates"), label: t("import.report.skippedDuplicates"),
value: report.skippedDuplicates, value: report.skippedDuplicates,
color: "text-amber-500", color: "text-[var(--accent)]",
}, },
{ {
icon: XCircle, icon: XCircle,
label: t("import.report.errors"), label: t("import.report.errors"),
value: report.errorCount, value: report.errorCount,
color: "text-red-500", color: "text-[var(--negative)]",
}, },
{ {
icon: Tag, icon: Tag,
@ -85,7 +85,7 @@ export default function ImportReportPanel({
{/* Errors list */} {/* Errors list */}
{report.errors.length > 0 && ( {report.errors.length > 0 && (
<div> <div>
<h3 className="text-sm font-semibold mb-2 text-red-500"> <h3 className="text-sm font-semibold mb-2 text-[var(--negative)]">
{t("import.report.errorDetails")} {t("import.report.errorDetails")}
</h3> </h3>
<div className="max-h-48 overflow-y-auto rounded-xl border border-[var(--border)]"> <div className="max-h-48 overflow-y-auto rounded-xl border border-[var(--border)]">
@ -104,7 +104,7 @@ export default function ImportReportPanel({
{report.errors.map((err, i) => ( {report.errors.map((err, i) => (
<tr key={i}> <tr key={i}>
<td className="px-3 py-2">{err.rowIndex + 1}</td> <td className="px-3 py-2">{err.rowIndex + 1}</td>
<td className="px-3 py-2 text-red-500">{err.message}</td> <td className="px-3 py-2 text-[var(--negative)]">{err.message}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View file

@ -35,9 +35,9 @@ export default function ImportPage() {
{/* Error banner */} {/* Error banner */}
{state.error && ( {state.error && (
<div className="mb-4 p-3 rounded-xl bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 flex items-center gap-2"> <div className="mb-4 p-3 rounded-xl bg-[var(--card)] border-2 border-[var(--negative)] flex items-center gap-2">
<AlertCircle size={16} className="text-red-500 shrink-0" /> <AlertCircle size={16} className="text-[var(--negative)] shrink-0" />
<p className="text-sm text-red-700 dark:text-red-300"> <p className="text-sm text-[var(--foreground)]">
{state.error} {state.error}
</p> </p>
</div> </div>

View file

@ -32,6 +32,19 @@ export async function createImportedFile(file: {
notes?: string; notes?: string;
}): Promise<number> { }): Promise<number> {
const db = await getDb(); const db = await getDb();
// Check if file already exists (e.g. from a previous failed import)
const existing = await db.select<ImportedFile[]>(
"SELECT id FROM imported_files WHERE source_id = $1 AND file_hash = $2",
[file.source_id, file.file_hash]
);
if (existing.length > 0) {
await db.execute(
`UPDATE imported_files SET filename = $1, row_count = $2, status = $3, notes = $4, import_date = CURRENT_TIMESTAMP WHERE id = $5`,
[file.filename, file.row_count, file.status, file.notes || null, existing[0].id]
);
return existing[0].id;
}
const result = await db.execute( const result = await db.execute(
`INSERT INTO imported_files (source_id, filename, file_hash, row_count, status, notes) `INSERT INTO imported_files (source_id, filename, file_hash, row_count, status, notes)
VALUES ($1, $2, $3, $4, $5, $6)`, VALUES ($1, $2, $3, $4, $5, $6)`,