From 0643316ac8c2b54d878e62c88d604b36c22f2d43 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 13 Aug 2026 15:16:41 +0200 Subject: [PATCH] feat(import): show SIE import history with undo on the import tab (#1574) * feat(import): show SIE import history with undo on the import tab The list route (GET /api/import/sie) and the undo route (DELETE /api/import/sie/[id]/undo) both existed, but no UI ever called the list: once the post-import result screen was gone, past imports could not be seen or undone. Add a fold-open 'Tidigare SIE-importer' row on the Importera tab (same expanded pattern as the cloud-backup row) that lazy-loads a history table: filename, date, fiscal year, voucher count, status, and an undo button on completed rows. Undo confirms through DestructiveConfirmDialog (voucher count, IB cleared, documents detached but kept; plus a voucher-gap warning for large imports), keeps the dialog open for the long-running DELETE, and refetches on completion. Co-Authored-By: Claude Fable 5 * test(import): cover the SIE list and undo routes The base list route had no test file (its siblings all do) and the undo route was only covered indirectly. Add route tests through the real withRouteContext wrapper: 401, the { data, count, limit, offset } shape with company scoping and range math, the status filter, and the Swedish 500 path for the list; 401, 403 viewer, the { success, deletedEntries } passthrough, and the SIE_UNDO_FAILED envelope (reason in details) for undo, with undoSIEImport mocked. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- app/(dashboard)/import/page.tsx | 13 + .../sie/[id]/undo/__tests__/route.test.ts | 128 ++++++++++ app/api/import/sie/__tests__/route.test.ts | 122 ++++++++++ components/import/SIEImportHistory.tsx | 230 ++++++++++++++++++ messages/en.json | 23 ++ messages/sv.json | 23 ++ 6 files changed, 539 insertions(+) create mode 100644 app/api/import/sie/[id]/undo/__tests__/route.test.ts create mode 100644 app/api/import/sie/__tests__/route.test.ts create mode 100644 components/import/SIEImportHistory.tsx diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 3c271137..e553e6a4 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -107,6 +107,7 @@ const SIEPreviewStep = dynamic(() => import('@/components/import/SIEPreviewStep' const AccountMappingStep = dynamic(() => import('@/components/import/AccountMappingStep'), { loading: ImportStepLoading }) const ImportReviewStep = dynamic(() => import('@/components/import/ImportReviewStep'), { loading: ImportStepLoading }) const ImportResultStep = dynamic(() => import('@/components/import/ImportResultStep'), { loading: ImportStepLoading }) +const SIEImportHistory = dynamic(() => import('@/components/import/SIEImportHistory'), { loading: ImportStepLoading }) // ============================================================ // Bank File Import Wizard Steps @@ -2050,6 +2051,7 @@ export default function ImportPage() { const [view, setView] = useState<'import' | 'export'>('import') const [sieDialogOpen, setSieDialogOpen] = useState(false) const [cloudOpen, setCloudOpen] = useState(false) + const [sieHistoryOpen, setSieHistoryOpen] = useState(false) const [userId, setUserId] = useState('') const [exportPeriodId, setExportPeriodId] = useState(null) const [exportExcludeClosing, setExportExcludeClosing] = useState(true) @@ -2253,7 +2255,18 @@ export default function ImportPage() { sub={t('sie_description')} onClick={() => setMode('sie')} /> + setSieHistoryOpen((v) => !v)} + /> + {sieHistoryOpen && ( +
+ +
+ )}

{t('pgnote')}

) : ( diff --git a/app/api/import/sie/[id]/undo/__tests__/route.test.ts b/app/api/import/sie/[id]/undo/__tests__/route.test.ts new file mode 100644 index 00000000..ef2e91ef --- /dev/null +++ b/app/api/import/sie/[id]/undo/__tests__/route.test.ts @@ -0,0 +1,128 @@ +/** + * Tests for DELETE /api/import/sie/[id]/undo. + * + * Exercises the route through the real withRouteContext wrapper, mocking its + * auth/company/write dependencies and the undoSIEImport service. Covers: 401, + * 403 viewer, the { success, deletedEntries } passthrough, and the + * SIE_UNDO_FAILED envelope with the service's Swedish reason in details. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + createMockRouteParams, + parseJsonResponse, +} from '@/tests/helpers' + +const { supabase, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const undoSIEImportMock = vi.fn() +vi.mock('@/lib/import/sie-import', () => ({ + undoSIEImport: (...args: unknown[]) => undoSIEImportMock(...args), +})) + +import { DELETE } from '../route' + +const routeParams = () => createMockRouteParams({ id: 'import-1' }) + +describe('DELETE /api/import/sie/[id]/undo', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1/undo', { method: 'DELETE' }), + routeParams(), + ) + + expect(response.status).toBe(401) + expect(undoSIEImportMock).not.toHaveBeenCalled() + }) + + it('returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1/undo', { method: 'DELETE' }), + routeParams(), + ) + + expect(response.status).toBe(403) + expect(undoSIEImportMock).not.toHaveBeenCalled() + }) + + it('passes through { success, deletedEntries } on a successful undo', async () => { + undoSIEImportMock.mockResolvedValue({ success: true, deletedEntries: 214 }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1/undo', { method: 'DELETE' }), + routeParams(), + ) + const { status, body } = await parseJsonResponse<{ + success: boolean + deletedEntries: number + }>(response) + + expect(status).toBe(200) + expect(body).toEqual({ success: true, deletedEntries: 214 }) + expect(undoSIEImportMock).toHaveBeenCalledWith(supabase, 'company-1', 'import-1', 'user-1') + }) + + it('returns the SIE_UNDO_FAILED envelope when the service refuses', async () => { + undoSIEImportMock.mockResolvedValue({ + success: false, + deletedEntries: 0, + error: 'Kan inte ångra import i ett låst eller stängt räkenskapsår. Öppna perioden först.', + }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1/undo', { method: 'DELETE' }), + routeParams(), + ) + const { status, body } = await parseJsonResponse<{ + error: { + code: string + message: string + message_en: string + details?: { reason?: string } + } + }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('SIE_UNDO_FAILED') + expect(body.error.message).toBe('SIE-importen kunde inte ångras.') + expect(body.error.message_en).toBe('Failed to undo SIE import.') + expect(body.error.details?.reason).toBe( + 'Kan inte ångra import i ett låst eller stängt räkenskapsår. Öppna perioden först.', + ) + }) +}) diff --git a/app/api/import/sie/__tests__/route.test.ts b/app/api/import/sie/__tests__/route.test.ts new file mode 100644 index 00000000..2fec087c --- /dev/null +++ b/app/api/import/sie/__tests__/route.test.ts @@ -0,0 +1,122 @@ +/** + * Tests for GET /api/import/sie (the SIE import list). + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company dependencies and injecting a queued Supabase mock via + * requireAuth. Covers: 401, the { data, count, limit, offset } happy-path + * shape, the status filter, and the 500 path returning a Swedish error. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + createMockRouteParams, + parseJsonResponse, +} from '@/tests/helpers' + +const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET } from '../route' + +// Next.js 16 always passes a params promise, even on static routes. +const staticParams = () => createMockRouteParams({}) + +const makeImportRow = (overrides: Record = {}) => ({ + id: 'import-1', + company_id: 'company-1', + filename: 'bokforing-2025.se', + fiscal_year_start: '2025-01-01', + fiscal_year_end: '2025-12-31', + transactions_count: 214, + status: 'completed', + imported_at: '2026-08-01T09:00:00Z', + created_at: '2026-08-01T08:59:00Z', + ...overrides, +}) + +describe('GET /api/import/sie', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await GET(createMockRequest('/api/import/sie'), staticParams()) + + expect(response.status).toBe(401) + }) + + it('returns { data, count, limit, offset } with defaults', async () => { + const rows = [makeImportRow(), makeImportRow({ id: 'import-2', status: 'undone' })] + enqueue({ data: rows, count: 2 }) + + const response = await GET(createMockRequest('/api/import/sie'), staticParams()) + const { status, body } = await parseJsonResponse<{ + data: { id: string }[] + count: number + limit: number + offset: number + }>(response) + + expect(status).toBe(200) + expect(body.data).toHaveLength(2) + expect(body.data[0].id).toBe('import-1') + expect(body.count).toBe(2) + expect(body.limit).toBe(20) + expect(body.offset).toBe(0) + + // Scoped to the active company; ordered newest-first; default range 0-19. + expect(findCalls('sie_imports', 'eq')).toContainEqual(['company_id', 'company-1']) + expect(findCalls('sie_imports', 'order')).toContainEqual(['created_at', { ascending: false }]) + expect(findCalls('sie_imports', 'range')).toContainEqual([0, 19]) + }) + + it('applies the status filter and custom limit/offset', async () => { + enqueue({ data: [makeImportRow()], count: 1 }) + + const response = await GET( + createMockRequest('/api/import/sie', { + searchParams: { status: 'completed', limit: '5', offset: '10' }, + }), + staticParams(), + ) + const { status, body } = await parseJsonResponse<{ limit: number; offset: number }>(response) + + expect(status).toBe(200) + expect(body.limit).toBe(5) + expect(body.offset).toBe(10) + expect(findCalls('sie_imports', 'eq')).toContainEqual(['status', 'completed']) + expect(findCalls('sie_imports', 'range')).toContainEqual([10, 14]) + }) + + it('returns 500 with a Swedish error message on a database error', async () => { + enqueue({ data: null, error: { message: 'connection reset by peer' } }) + + const response = await GET(createMockRequest('/api/import/sie'), staticParams()) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(typeof body.error).toBe('string') + // The raw driver message must not leak; the mapped message is Swedish. + expect(body.error).not.toContain('connection reset') + expect(body.error).toBe('Något gick fel. Försök igen.') + }) +}) diff --git a/components/import/SIEImportHistory.tsx b/components/import/SIEImportHistory.tsx new file mode 100644 index 00000000..fc8a5f92 --- /dev/null +++ b/components/import/SIEImportHistory.tsx @@ -0,0 +1,230 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Undo2 } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { useToast } from '@/components/ui/use-toast' +import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog' +import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { cn, formatDate } from '@/lib/utils' + +/** + * Subset of the sie_imports row (GET /api/import/sie) actually rendered here. + * `status` stays a plain string: the DB CHECK also allows values this table + * never renders specially (e.g. the legacy 'mapped'), which fall back to raw + * muted text instead of crashing on a missing translation key. + */ +interface SIEImportListRow { + id: string + filename: string + fiscal_year_start: string | null + fiscal_year_end: string | null + transactions_count: number + status: string + imported_at: string | null + created_at: string +} + +/** + * At or above this voucher count the undo confirm adds a warning that undoing + * can leave gaps in the voucher numbering when other vouchers were booked + * after the import (gaps need a documented explanation per BFNAR 2013:2). + */ +const GAP_WARNING_VOUCHER_COUNT = 100 + +const STATUS_LABEL_KEY: Record = { + completed: 'sie_history_status_completed', + undone: 'sie_history_status_undone', + replaced: 'sie_history_status_replaced', + failed: 'sie_history_status_failed', + pending: 'sie_history_status_pending', +} + +/** + * Chips mark exceptions (design.md convention 5): the normal 'completed' + * state renders as muted text; only deviating states get a Badge. + */ +const STATUS_BADGE_VARIANT: Record = { + undone: 'secondary', + replaced: 'secondary', + failed: 'destructive', + pending: 'warning', +} + +/** + * History of past SIE imports with per-row undo for completed ones. + * Rendered fold-open from the 'Tidigare SIE-importer' row on the import tab. + */ +export default function SIEImportHistory() { + const t = useTranslations('import') + const { toast } = useToast() + const [rows, setRows] = useState(null) + const [loadFailed, setLoadFailed] = useState(false) + const [pendingUndo, setPendingUndo] = useState(null) + + const fetchImports = useCallback(async () => { + try { + const res = await fetch('/api/import/sie?limit=20') + if (!res.ok) { + setLoadFailed(true) + return + } + const data = await res.json() + setRows(Array.isArray(data.data) ? data.data : []) + setLoadFailed(false) + } catch { + setLoadFailed(true) + } + }, []) + + useEffect(() => { + void fetchImports() + }, [fetchImports]) + + // Deliberately no client-side timeout: undoing a large import can take + // minutes (the route runs with maxDuration 300) and the confirm dialog + // stays open with its spinner until this resolves. + const handleUndoConfirm = useCallback(async () => { + if (!pendingUndo) return + try { + const res = await fetch(`/api/import/sie/${pendingUndo.id}/undo`, { method: 'DELETE' }) + const data = await res.json() + + if (!res.ok) { + toast({ + title: t('sie_history_undo_failed'), + description: getErrorMessage(data), + variant: 'destructive', + }) + return + } + + toast({ + title: t('sie_history_undo_success_title'), + description: t('sie_history_undo_success', { count: data.deletedEntries ?? 0 }), + }) + await fetchImports() + } catch (err) { + toast({ + title: t('sie_history_undo_failed'), + description: getErrorMessage(err), + variant: 'destructive', + }) + } + }, [pendingUndo, fetchImports, t, toast]) + + const fiscalYearLabel = (row: SIEImportListRow): string => { + if (row.fiscal_year_start && row.fiscal_year_end) { + return t('sie_history_fiscal_year_range', { + start: formatDate(row.fiscal_year_start), + end: formatDate(row.fiscal_year_end), + }) + } + if (row.fiscal_year_start) return formatDate(row.fiscal_year_start) + if (row.fiscal_year_end) return formatDate(row.fiscal_year_end) + return '-' + } + + const statusCell = (status: string) => { + const labelKey = STATUS_LABEL_KEY[status] + const label = labelKey ? t(labelKey) : status + const variant = STATUS_BADGE_VARIANT[status] + if (!variant) { + return {label} + } + return ( + + {label} + + ) + } + + if (loadFailed) { + return

{t('sie_history_load_error')}

+ } + + if (rows === null) { + return ( +
+ + + +
+ ) + } + + if (rows.length === 0) { + return

{t('sie_history_empty')}

+ } + + const confirmDescription = pendingUndo + ? t('sie_history_undo_confirm_description', { count: pendingUndo.transactions_count }) + + (pendingUndo.transactions_count >= GAP_WARNING_VOUCHER_COUNT + ? '\n\n' + t('sie_history_undo_gap_warning') + : '') + : '' + + return ( +
+
+ + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
{t('sie_history_col_file')}{t('sie_history_col_date')}{t('sie_history_col_fiscal_year')}{t('sie_history_col_vouchers')}{t('sie_history_col_status')} + {t('sie_history_undo_button')} +
{row.filename} + {formatDate(row.imported_at ?? row.created_at)} + + {fiscalYearLabel(row)} + {row.transactions_count}{statusCell(row.status)} + {row.status === 'completed' && ( + + )} +
+
+ + { + if (!open) setPendingUndo(null) + }} + title={t('sie_history_undo_confirm_title')} + description={confirmDescription} + confirmLabel={t('sie_history_undo_confirm_label')} + onConfirm={handleUndoConfirm} + /> +
+ ) +} diff --git a/messages/en.json b/messages/en.json index 1471726b..6a532d7e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6863,6 +6863,29 @@ "cloud_row_description": "Continuous backup of the archive to Google Drive", "help_text": "Every way in and out lives here: bank connections, file imports and migrations from other systems, plus SIE export and backups. Every import is reviewed before anything is booked.", "pgnote": "Every import goes through the same steps: upload, map columns, review, result. Nothing is booked without you seeing it first.", + "sie_history_title": "Previous SIE imports", + "sie_history_description": "View history and undo a completed import", + "sie_history_empty": "No previous SIE imports.", + "sie_history_load_error": "Could not load the import history.", + "sie_history_col_file": "File name", + "sie_history_col_date": "Date", + "sie_history_col_fiscal_year": "Fiscal year", + "sie_history_col_vouchers": "Vouchers", + "sie_history_col_status": "Status", + "sie_history_fiscal_year_range": "{start} to {end}", + "sie_history_status_completed": "Completed", + "sie_history_status_undone": "Undone", + "sie_history_status_replaced": "Replaced", + "sie_history_status_failed": "Failed", + "sie_history_status_pending": "In progress", + "sie_history_undo_button": "Undo", + "sie_history_undo_confirm_title": "Undo the entire import?", + "sie_history_undo_confirm_label": "Undo import", + "sie_history_undo_confirm_description": "This deletes {count, plural, =1 {1 voucher} other {# vouchers}} and clears opening balances from this import. Attached documents are detached but kept.", + "sie_history_undo_gap_warning": "This import contains many vouchers: if anything was booked after the import, gaps can appear in the voucher numbering, and gaps require a documented explanation.", + "sie_history_undo_success_title": "Import undone", + "sie_history_undo_success": "{count, plural, =1 {1 voucher was deleted} other {# vouchers were deleted}}.", + "sie_history_undo_failed": "Could not undo import", "bank_format_wise_statement": "Wise balance statement" }, "annualReportStudio": { diff --git a/messages/sv.json b/messages/sv.json index 25524b8a..21ef830b 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -6863,6 +6863,29 @@ "cloud_row_description": "Löpande säkerhetskopia av arkivet till Google Drive", "help_text": "Här samlas alla vägar in och ut: bankkoppling, filimporter och flytt från andra system, samt export av SIE och säkerhetskopior. Varje import granskas innan något bokförs.", "pgnote": "Varje import går genom samma steg: ladda upp, mappa kolumner, granska, resultat. Inget bokförs utan att du ser det först.", + "sie_history_title": "Tidigare SIE-importer", + "sie_history_description": "Se historik och ångra en genomförd import", + "sie_history_empty": "Inga tidigare SIE-importer.", + "sie_history_load_error": "Kunde inte hämta importhistoriken.", + "sie_history_col_file": "Filnamn", + "sie_history_col_date": "Datum", + "sie_history_col_fiscal_year": "Räkenskapsår", + "sie_history_col_vouchers": "Verifikat", + "sie_history_col_status": "Status", + "sie_history_fiscal_year_range": "{start} till {end}", + "sie_history_status_completed": "Slutförd", + "sie_history_status_undone": "Ångrad", + "sie_history_status_replaced": "Ersatt", + "sie_history_status_failed": "Misslyckad", + "sie_history_status_pending": "Pågående", + "sie_history_undo_button": "Ångra", + "sie_history_undo_confirm_title": "Ångra hela importen?", + "sie_history_undo_confirm_label": "Ångra import", + "sie_history_undo_confirm_description": "Detta raderar {count, plural, =1 {1 verifikation} other {# verifikationer}} och rensar ingående balanser från den här importen. Bifogade dokument blir okopplade men finns kvar.", + "sie_history_undo_gap_warning": "Importen innehåller många verifikat: om annat har bokförts efter importen kan luckor uppstå i verifikatnumreringen, och luckor kräver en dokumenterad förklaring.", + "sie_history_undo_success_title": "Import ångrad", + "sie_history_undo_success": "{count, plural, =1 {1 verifikation raderades} other {# verifikationer raderades}}.", + "sie_history_undo_failed": "Kunde inte ångra import", "bank_format_wise_statement": "Wise kontoutdrag" }, "annualReportStudio": {