Files
accounted/components/transactions/TransactionForm.tsx
T
Jakob Wennberg a25e75be25 fix: Nordea Business CSV variants, API keys UI polish, transaction categorization (#183)
* fix: MCP OAuth 303 redirect, send dialog auto-close, bank details null payload

- OAuth authorize: use 303 See Other instead of default 307, which
  preserved POST method and caused Claude's callback to return 405
- SendInvoiceDialog: close dialog and show toast after email send
  instead of leaving a success message that requires manual close
- BankDetailsSetupDialog: omit empty fields from payload instead of
  sending null, which fails Zod validation on non-nullable schema fields

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

* refactor: remove dead sentMessage state and fix stale comment

Remove sentMessage state, its success banner JSX, and the CheckCircle2
import — all unreachable after the dialog now auto-closes on email send.
Fix stale "to null" comment in BankDetailsSetupDialog.

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

* fix: support Nordea Business CSV variants, polish API keys UI, fix transaction categorization

- Extend Nordea Business bank file parser to handle three CSV export
  formats (classic, Betalare/Mottagare variant, Bokföringsdatum variant)
  with proper detection guards against SEB/LF misidentification
- Rework ApiKeysPanel: add CopyBlock component, destructive confirm on
  revoke, collapsible API-key-based connection methods, Claude.ai OAuth
  instructions as recommended path, simplified scope badges
- Stop deriving is_business from category on manual transaction creation;
  set null so categorization flow handles it correctly
- Show categorize button when journal_entry_id is missing regardless of
  is_business value

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

* fix: address PR review — await clipboard, fix zero-scope label, simplify condition

- Await navigator.clipboard.writeText and catch failures
- Change zero-scope label from "Enbart läs" to "Inga behörigheter"
- Simplify redundant ternary condition in TransactionHistoryList

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

* fix: repair broken ternary in TransactionHistoryList JSX

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-07 11:11:47 +02:00

198 lines
6.4 KiB
TypeScript

'use client'
import { useEffect } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2 } from 'lucide-react'
import type { CreateTransactionInput, TransactionCategory, Currency } from '@/types'
const schema = z.object({
date: z.string().min(1, 'Datum krävs'),
description: z.string().min(1, 'Beskrivning krävs'),
amount: z.number().refine((n) => n !== 0, 'Belopp måste anges'),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
category: z.string().optional(),
is_business: z.boolean().optional(),
notes: z.string().optional(),
})
type FormData = z.infer<typeof schema>
interface TransactionFormProps {
onSubmit: (data: CreateTransactionInput) => Promise<void>
isLoading: boolean
}
const categories: { value: TransactionCategory; label: string; isIncome?: boolean }[] = [
{ value: 'income_services', label: 'Intäkt: Tjänster', isIncome: true },
{ value: 'income_products', label: 'Intäkt: Produkter', isIncome: true },
{ value: 'income_other', label: 'Intäkt: Övrigt', isIncome: true },
{ value: 'expense_equipment', label: 'Kostnad: Utrustning' },
{ value: 'expense_software', label: 'Kostnad: Programvara' },
{ value: 'expense_travel', label: 'Kostnad: Resor' },
{ value: 'expense_office', label: 'Kostnad: Kontor' },
{ value: 'expense_marketing', label: 'Kostnad: Marknadsföring' },
{ value: 'expense_professional_services', label: 'Kostnad: Konsulter' },
{ value: 'expense_education', label: 'Kostnad: Utbildning' },
{ value: 'expense_other', label: 'Kostnad: Övrigt' },
{ value: 'private', label: 'Privat (ej avdragsgillt)' },
]
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
export default function TransactionForm({ onSubmit, isLoading }: TransactionFormProps) {
const {
register,
handleSubmit,
control,
watch,
setValue,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
date: '',
description: '',
amount: 0,
currency: 'SEK',
category: undefined,
is_business: undefined,
notes: '',
},
})
// Set date default on client only to avoid hydration mismatch
useEffect(() => {
setValue('date', format(new Date(), 'yyyy-MM-dd'))
}, [])
const watchCategory = watch('category')
const isPrivate = watchCategory === 'private'
const isIncome = categories.find((c) => c.value === watchCategory)?.isIncome
const onFormSubmit = (data: FormData) => {
onSubmit({
date: data.date,
description: data.description,
amount: data.amount,
currency: data.currency,
category: data.category as TransactionCategory,
is_business: undefined,
notes: data.notes,
})
}
return (
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="date">Datum *</Label>
<Input id="date" type="date" {...register('date')} />
{errors.date && (
<p className="text-sm text-destructive">{errors.date.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="currency">Valuta</Label>
<Controller
name="currency"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{currencies.map((currency) => (
<SelectItem key={currency} value={currency}>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Beskrivning *</Label>
<Input
id="description"
placeholder="T.ex. Adobe Creative Cloud"
{...register('description')}
/>
{errors.description && (
<p className="text-sm text-destructive">{errors.description.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="amount">Belopp * (negativt för utgift)</Label>
<Input
id="amount"
type="number"
step="0.01"
placeholder="-500"
{...register('amount', { valueAsNumber: true })}
/>
{errors.amount && (
<p className="text-sm text-destructive">{errors.amount.message}</p>
)}
<p className="text-xs text-muted-foreground">
Ange positivt belopp för intäkter, negativt för kostnader
</p>
</div>
<div className="space-y-2">
<Label>Kategori (valfritt)</Label>
<Controller
name="category"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Välj kategori" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Anteckningar</Label>
<Textarea
id="notes"
placeholder="Valfria anteckningar..."
{...register('notes')}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara transaktion'
)}
</Button>
</form>
)
}