Files
accounted/components/import/BankFileConfirmStep.tsx
T
Jakob Wennberg 7bf7565852 feat: delete last voucher, notes field, schema cache fix (#230)
* feat: delete last voucher, notes field, schema cache fix

Address three customer feedback items from William (wigu.se):

1. Delete last voucher per series (Fortnox model):
   - New `delete_last_voucher` RPC with full safety checks (last-in-series,
     open period, no references, owner/admin only)
   - Session variable bypass for immutability/retention/line triggers
   - Full JSONB audit trail (BFNAR 2013:2 behandlingshistorik)
   - DELETE endpoint + UI with confirmation dialogs
   - Storno restoration when deleting a reversal entry

2. Notes/comment field on vouchers:
   - `notes` column on journal_entries (always-editable internal metadata)
   - Immutability trigger updated to allow notes-only updates on posted entries
   - PATCH endpoint, inline-edit UI on detail page, form textarea

3. Schema cache fix:
   - NOTIFY pgrst applied to production (immediate fix)
   - Retroactive migration + CLAUDE.md migration rule added

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — tighten trigger, lock voucher sequence

P1: The notes-only exception in enforce_journal_entry_immutability was
too broad — it only checked 7 verifikation fields, allowing silent
mutation of correction_of_id, reverses_id, reversed_by_id, committed_at,
and user_id on posted entries. Now guards all metadata fields; only
notes and updated_at may differ.

P2: Lock voucher_sequences row FOR UPDATE before the MAX(voucher_number)
check in delete_last_voucher to serialise against concurrent
commit_journal_entry calls, preventing voucher number gaps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:12:03 +02:00

215 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
ArrowLeft,
Loader2,
Play,
FileText,
Link2,
Calendar,
Lock,
Landmark,
} from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { createClient } from '@/lib/supabase/client'
import type { BankFileParseResult } from '@/lib/import/bank-file/types'
interface BankAccount {
account_number: string
account_name: string
}
interface BankFileConfirmStepProps {
parseResult: BankFileParseResult
onExecute: (options: { skip_duplicates: boolean; auto_categorize: boolean; settlement_account?: string }) => void
onBack: () => void
isLoading: boolean
}
export default function BankFileConfirmStep({
parseResult,
onExecute,
onBack,
isLoading,
}: BankFileConfirmStepProps) {
const { canWrite } = useCanWrite()
const { transactions, stats, date_from, date_to } = parseResult
const refsCount = transactions.filter((t) => t.reference).length
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([])
const [selectedAccount, setSelectedAccount] = useState('1930')
useEffect(() => {
async function fetchBankAccounts() {
const supabase = createClient()
const { data } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('is_active', true)
.gte('account_number', '1900')
.lte('account_number', '1999')
.order('account_number')
if (data && data.length > 0) {
setBankAccounts(data)
// Default to 1930 if available, otherwise first account
const has1930 = data.some(a => a.account_number === '1930')
if (!has1930) setSelectedAccount(data[0].account_number)
}
}
fetchBankAccounts()
}, [])
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24 space-y-6">
<div className="relative">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
<div className="text-center space-y-2">
<p className="text-lg font-medium">Importerar transaktioner...</p>
<p className="text-sm text-muted-foreground">
{stats.parsed_rows} transaktioner bearbetas
</p>
</div>
<div className="w-48 h-1 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full animate-pulse" style={{ width: '60%' }} />
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Summary */}
<Card>
<CardHeader>
<CardTitle>Bekräfta import</CardTitle>
<CardDescription>
Granska sammanfattningen och importera transaktionerna.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-xs">Transaktioner</span>
</div>
<p className="text-xl font-display font-medium tabular-nums">{stats.parsed_rows}</p>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Calendar className="h-4 w-4" />
<span className="text-xs">Period</span>
</div>
<p className="text-sm font-medium">
{date_from} {date_to}
</p>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-xs">Inkomster</span>
</div>
<p className="text-xl font-display font-medium tabular-nums">
{formatCurrency(stats.total_income)}
</p>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-xs">Leverantörsfakturor</span>
</div>
<p className="text-xl font-display font-medium tabular-nums">
{formatCurrency(stats.total_expenses)}
</p>
</div>
</div>
{/* Bank account selector */}
{bankAccounts.length > 1 && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Landmark className="h-4 w-4 text-muted-foreground" />
Bankkonto
</Label>
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
<SelectTrigger className="w-full sm:w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{bankAccounts.map((account) => (
<SelectItem key={account.account_number} value={account.account_number}>
<span className="font-mono">{account.account_number}</span>
{' '}
{account.account_name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Välj vilket bankkonto transaktionerna ska bokföras mot.
</p>
</div>
)}
{/* Additional info */}
{refsCount > 0 && (
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="text-primary border-primary/30">
<Link2 className="mr-1 h-3 w-3" />
{refsCount} med OCR/referens
</Badge>
</div>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack} disabled={isLoading}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button
className="min-h-11"
onClick={() => onExecute({
skip_duplicates: true,
auto_categorize: false,
settlement_account: selectedAccount !== '1930' ? selectedAccount : undefined,
})}
disabled={isLoading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Importerar...
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
Importera {stats.parsed_rows} transaktioner
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
Importera {stats.parsed_rows} transaktioner
</>
)}
</Button>
</div>
</div>
)
}