Bug/accounting ps eu (#474)

* feat(api): implement commit functionality for journal entries

* fix(extensions): make ExtensionSettings.clear() a real delete so disconnect flows work

The 2026-03-30 multi-tenant refactor dropped all RLS policies on extension_data
and recreated only SELECT/INSERT/UPDATE. Combined with `value jsonb NOT NULL`,
every extension that called `settings.set(key, null)` to clear stored state
(cloud-backup disconnect, skatteverket OAuth/AGI cleanup, arcim-migration
consent reset) silently failed — the upsert hit the NOT NULL constraint and
the error was swallowed, leaving users stuck with stale connection rows.

Adds an `extension_data_delete` RLS policy, a `clear(key)` method backed by a
real DELETE, switches the four affected handlers, and makes `set()` throw on
Supabase error so this class of silent failure can't recur.

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

* feat(journal-entries): add draft saving functionality to journal entry form

* feat: add periodisk sammanställning report generation and CSV export

- Implemented period date helpers in `period-dates.ts` for calculating start and end dates based on period type (monthly, quarterly, yearly).
- Created `periodisk-sammanstallning.ts` to generate the periodisk sammanställning report, including data fetching, validation, and warning handling.
- Developed CSV serializer in `periodisk-sammanstallning-csv.ts` for exporting the report in SKV574008 format.
- Added new columns to `company_settings` for storing periodisk sammanställning settings and tax contact information via migration.
- Introduced a new migration to add a `paid_with_private_funds` flag to `supplier_invoices` for tracking out-of-pocket expenses.
- Updated journal entries to include the new source type for privately paid supplier invoices.

* feat(migrations): add paid_with_private_funds flag to supplier_invoices and expand journal_entries.source_type CHECK

* fix(ai_requests): drop existing policies and trigger before creating new ones

* fix(migrations): ensure extension_data has a proper DELETE policy for ExtensionSettings.clear()

* fix(supplier-invoices): update error handling for invalid input in POST request

* fix: correct capitalization in project title

* fix(migrations): resolve duplicate version 20260513120000

Two migrations shared the same timestamp prefix, causing
schema_migrations_pkey collision on Supabase preview branches.
Bump extension_data_delete_policy to 20260513120001.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-14 01:10:44 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent ba45b8f661
commit c8461397c8
40 changed files with 3147 additions and 55 deletions
+80 -10
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -89,6 +89,8 @@ export default function JournalEntryForm({
const [nextVoucherNumber, setNextVoucherNumber] = useState<number | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [showReview, setShowReview] = useState(false)
const [isSavingDraft, setIsSavingDraft] = useState(false)
const saveAsDraftRef = useRef(false)
const [showNoDocWarning, setShowNoDocWarning] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
@@ -332,7 +334,8 @@ export default function JournalEntryForm({
return base
})
const url = submitUrl ?? '/api/bookkeeping/journal-entries'
const baseUrl = submitUrl ?? '/api/bookkeeping/journal-entries'
const url = saveAsDraftRef.current ? `${baseUrl}?as_draft=true` : baseUrl
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -355,6 +358,7 @@ export default function JournalEntryForm({
const handleConfirm = async () => {
setIsSubmitting(true)
saveAsDraftRef.current = false
try {
const result = await runSubmit()
@@ -414,6 +418,61 @@ export default function JournalEntryForm({
}
}
const handleSaveDraft = async () => {
if (!selectedPeriod || !description || !isBalanced || periodMismatch) return
setIsSavingDraft(true)
saveAsDraftRef.current = true
try {
const result = await runSubmit()
const journalEntryId = result.data?.id ?? result.journal_entry_id
if (journalEntryId && uploadedFiles.length > 0) {
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
for (const file of filesToLink) {
try {
await fetch(`/api/documents/${file.id}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ journal_entry_id: journalEntryId }),
})
} catch {
// Non-blocking: user can attach underlag from the draft view
}
}
}
toast({
title: 'Utkast sparat',
description: 'Utkastet kan bokföras från bokföringssidan.',
})
setDescription('')
setNotes('')
setUploadedFiles([])
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
setEntryCurrency('SEK')
setExchangeRate('')
setForeignAmount('')
onCreated?.()
if (journalEntryId) {
onEntryCreated?.(journalEntryId)
}
} catch (err) {
if (err instanceof Error && err.message === 'cancelled') {
// Activation dialog dismissed — silent
} else {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: 'Kunde inte spara utkast',
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
variant: 'destructive',
})
}
} finally {
saveAsDraftRef.current = false
setIsSavingDraft(false)
}
}
const formContent = (
<div className="space-y-4">
<div className={`grid gap-4 grid-cols-1 ${
@@ -789,14 +848,25 @@ export default function JournalEntryForm({
)}
<div className="flex flex-col items-end gap-1">
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isUploading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
Granska & skapa
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleSaveDraft}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : 'Sparar som utkast utan att tilldela verifikationsnummer'}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara som utkast
</Button>
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
Granska & skapa
</Button>
</div>
{(!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && (
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
{!description && <p>Ange en beskrivning</p>}
+41 -1
View File
@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X, Copy } from 'lucide-react'
import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X, Copy, Lock } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { Input } from '@/components/ui/input'
import { AccountNumber } from '@/components/ui/account-number'
@@ -17,6 +17,9 @@ import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { JournalEntry, JournalEntryLine } from '@/types'
const NEEDS_ATTACHMENT = new Set([
@@ -34,7 +37,10 @@ interface Props {
export default function JournalEntryList({ periodId }: Props) {
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [entries, setEntries] = useState<JournalEntry[]>([])
const [committingId, setCommittingId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [count, setCount] = useState(0)
@@ -142,6 +148,28 @@ export default function JournalEntryList({ periodId }: Props) {
setExpandedId(expandedId === id ? null : id)
}
const handleCommit = async (entryId: string) => {
setCommittingId(entryId)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${entryId}/commit`, { method: 'POST' })
const result = await res.json()
if (res.ok) {
const posted = result.data
toast({
title: 'Verifikat bokfört',
description: `Verifikat ${posted?.voucher_series ?? ''}${posted?.voucher_number ?? ''} har bokförts.`,
})
await fetchEntries()
} else {
toast({ title: 'Kunde inte bokföra', description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
}
} catch {
toast({ title: 'Kunde inte bokföra verifikat', variant: 'destructive' })
} finally {
setCommittingId(null)
}
}
if (loading) {
return (
<Card>
@@ -443,6 +471,18 @@ export default function JournalEntryList({ periodId }: Props) {
/>
<div className="mt-4 pt-3 border-t flex flex-col sm:flex-row gap-2">
{entry.status === 'draft' && (
<Button
size="sm"
className="w-full sm:w-auto"
onClick={() => handleCommit(entry.id)}
disabled={!canWrite || committingId === entry.id}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : committingId === entry.id && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Bokför
</Button>
)}
<Button variant="outline" size="sm" className="w-full sm:w-auto" asChild>
<Link href={`/bookkeeping/${entry.id}`}>Visa detaljer</Link>
</Button>
@@ -0,0 +1,402 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Card, CardContent, 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 { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableHeader,
TableHead,
TableRow,
TableCell,
TableBody,
} from '@/components/ui/table'
import { Download, AlertCircle, AlertTriangle, FileText, ExternalLink } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import type {
PeriodiskSammanstallningReport,
PsPeriodType,
PsWarning,
} from '@/lib/reports/periodisk-sammanstallning'
function formatAmount(amount: number): string {
// Hela kronor — SKV 5740 har inga öre.
return amount.toLocaleString('sv-SE', { maximumFractionDigits: 0 })
}
function typeBadge(row: { services: number; goods: number; triangulation: number }) {
const types: string[] = []
if (row.services !== 0) types.push('Tjänster')
if (row.goods !== 0) types.push('Varor')
if (row.triangulation !== 0) types.push('Trepart')
return types.join(' + ') || '—'
}
function deadlineLabel(end: string): string {
// Inlämnas digitalt senast den 25:e månaden efter perioden.
const endDate = new Date(end)
const deadline = new Date(endDate.getFullYear(), endDate.getMonth() + 1, 25)
const months = [
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
]
return `${deadline.getDate()} ${months[deadline.getMonth()]} ${deadline.getFullYear()}`
}
export function PeriodiskSammanstallningView() {
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1
const currentQuarter = Math.ceil(currentMonth / 3)
const [periodType, setPeriodType] = useState<PsPeriodType>('quarterly')
const [year, setYear] = useState(currentYear)
const [period, setPeriod] = useState(currentQuarter)
const [data, setData] = useState<PeriodiskSammanstallningReport | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i)
const periodOptions = periodType === 'monthly'
? [
{ value: 1, label: 'Januari' }, { value: 2, label: 'Februari' },
{ value: 3, label: 'Mars' }, { value: 4, label: 'April' },
{ value: 5, label: 'Maj' }, { value: 6, label: 'Juni' },
{ value: 7, label: 'Juli' }, { value: 8, label: 'Augusti' },
{ value: 9, label: 'September' }, { value: 10, label: 'Oktober' },
{ value: 11, label: 'November' }, { value: 12, label: 'December' },
]
: [
{ value: 1, label: 'Kvartal 1 (jan-mar)' },
{ value: 2, label: 'Kvartal 2 (apr-jun)' },
{ value: 3, label: 'Kvartal 3 (jul-sep)' },
{ value: 4, label: 'Kvartal 4 (okt-dec)' },
]
useEffect(() => {
setPeriod(periodType === 'monthly' ? currentMonth : currentQuarter)
}, [periodType, currentMonth, currentQuarter])
const fetchReport = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(
`/api/reports/periodisk-sammanstallning?periodType=${periodType}&year=${year}&period=${period}`,
)
const result = await res.json()
if (result.error) {
setError(typeof result.error === 'string' ? result.error : result.error.message_sv ?? 'Något gick fel.')
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta periodisk sammanställning.')
} finally {
setLoading(false)
}
}
const downloadCsv = () => {
window.open(
`/api/reports/periodisk-sammanstallning/csv?periodType=${periodType}&year=${year}&period=${period}`,
'_blank',
)
}
const errors = data?.warnings.filter(w => w.level === 'error') ?? []
const cautions = data?.warnings.filter(w => w.level === 'warning') ?? []
const hasBlockingErrors = errors.length > 0
return (
<div className="space-y-6">
{/* Period selection */}
<Card>
<CardHeader>
<CardTitle className="text-base">Välj period</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-end gap-4">
<div>
<Label>Periodicitet</Label>
<select
value={periodType}
onChange={e => setPeriodType(e.target.value as PsPeriodType)}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="monthly">Månadsvis</option>
<option value="quarterly">Kvartalsvis</option>
</select>
</div>
<div>
<Label>År</Label>
<select
value={year}
onChange={e => setYear(parseInt(e.target.value))}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{yearOptions.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div>
<Label>Period</Label>
<select
value={period}
onChange={e => setPeriod(parseInt(e.target.value))}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{periodOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<Button onClick={fetchReport} disabled={loading}>
{loading ? 'Laddar…' : 'Hämta'}
</Button>
</div>
</CardContent>
</Card>
{error && (
<Card>
<CardContent className="p-6 flex items-start gap-3 text-destructive">
<AlertCircle className="h-5 w-5 mt-0.5 shrink-0" />
<div>{error}</div>
</CardContent>
</Card>
)}
{loading && (
<Card>
<CardContent className="p-6 space-y-3">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-32" />
</CardContent>
</Card>
)}
{data && !loading && (
<>
{/* Summary */}
<Card>
<CardHeader>
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle>
Periodisk sammanställning — {data.period.label}
</CardTitle>
{data.totals.rowCount > 0 && (
<Badge variant={hasBlockingErrors ? 'destructive' : 'secondary'}>
{data.totals.rowCount} {data.totals.rowCount === 1 ? 'rad' : 'rader'}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Tjänster (typ 3)</div>
<div className="font-display text-xl font-medium tabular-nums">
{formatAmount(data.totals.services)} kr
</div>
</div>
<div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Varor (typ 1)</div>
<div className="font-display text-xl font-medium tabular-nums">
{formatAmount(data.totals.goods)} kr
</div>
</div>
<div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Trepart (typ 2)</div>
<div className="font-display text-xl font-medium tabular-nums">
{formatAmount(data.totals.triangulation)} kr
</div>
</div>
</div>
{/* Reconciliation */}
{data.reconciliation.matches !== null && data.totals.rowCount > 0 && (
<div className="text-sm text-muted-foreground border-t pt-3">
{data.reconciliation.matches ? (
<span className="text-foreground">
✓ Stämmer mot momsdeklarationen
{' '}(Ruta 39: {formatAmount(data.reconciliation.ruta39 ?? 0)} kr,
{' '}Ruta 35: {formatAmount(data.reconciliation.ruta35 ?? 0)} kr,
{' '}Ruta 38: {formatAmount(data.reconciliation.ruta38 ?? 0)} kr)
</span>
) : (
<span className="text-destructive">
⚠ Avviker från momsdeklarationen — kontrollera bokföringen.
{' '}Ruta 39: {formatAmount(data.reconciliation.ruta39 ?? 0)} kr,
{' '}Ruta 35: {formatAmount(data.reconciliation.ruta35 ?? 0)} kr,
{' '}Ruta 38: {formatAmount(data.reconciliation.ruta38 ?? 0)} kr.
</span>
)}
</div>
)}
{data.reconciliation.matches === null && data.totals.rowCount > 0 && (
<div className="text-xs text-muted-foreground border-t pt-3">
Avstämning mot momsdeklarationen tillgänglig endast när PS-perioden sammanfaller med momsperioden.
</div>
)}
{/* Deadline banner */}
{data.totals.rowCount > 0 && (
<div className="text-sm border-t pt-3">
<strong>Inlämnas digitalt senast den {deadlineLabel(data.period.end)}</strong>
{' '}via e-tjänsten hos Skatteverket. Förseningsavgift: 1 250 kr.
</div>
)}
</CardContent>
</Card>
{/* Errors */}
{errors.length > 0 && (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-base text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
{errors.length} {errors.length === 1 ? 'fel måste' : 'fel måste'} åtgärdas
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{errors.map((w, i) => <WarningItem key={i} warning={w} />)}
<div className="text-xs text-muted-foreground pt-2 border-t">
CSV-nedladdning är blockerad tills alla fel åtgärdats.
</div>
</CardContent>
</Card>
)}
{/* Warnings */}
{cautions.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2 text-foreground">
<AlertTriangle className="h-4 w-4" />
Varningar ({cautions.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{cautions.map((w, i) => <WarningItem key={i} warning={w} />)}
</CardContent>
</Card>
)}
{/* Table */}
{data.rows.length > 0 ? (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Rader</CardTitle>
<Button
variant="outline"
size="sm"
onClick={downloadCsv}
disabled={hasBlockingErrors}
title={hasBlockingErrors ? 'Åtgärda blockerande fel innan CSV kan laddas ner' : undefined}
>
<Download className="h-4 w-4 mr-2" />
Ladda ner CSV (SKV 5740)
</Button>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Land</TableHead>
<TableHead>VAT-nummer</TableHead>
<TableHead className="text-right">Tjänster</TableHead>
<TableHead className="text-right">Varor</TableHead>
<TableHead className="text-right">Trepart</TableHead>
<TableHead>Typ</TableHead>
<TableHead>Kund</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.rows.map((row, i) => (
<TableRow key={i}>
<TableCell className="tabular-nums font-mono text-xs">{row.country}</TableCell>
<TableCell className="tabular-nums font-mono text-xs">{row.vatNumber}</TableCell>
<TableCell className="text-right tabular-nums">
{row.services !== 0 ? formatAmount(row.services) : '—'}
</TableCell>
<TableCell className="text-right tabular-nums">
{row.goods !== 0 ? formatAmount(row.goods) : '—'}
</TableCell>
<TableCell className="text-right tabular-nums">
{row.triangulation !== 0 ? formatAmount(row.triangulation) : '—'}
</TableCell>
<TableCell className="text-xs text-muted-foreground">{typeBadge(row)}</TableCell>
<TableCell className="text-xs">
{row.customerId
? <Link href={`/customers/${row.customerId}`} className="underline-offset-4 hover:underline">{row.customerName ?? '—'}</Link>
: <span className="text-muted-foreground">—</span>
}
</TableCell>
</TableRow>
))}
<TableRow className="bg-muted/30 font-medium">
<TableCell colSpan={2}>Totalt</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(data.totals.services)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(data.totals.goods)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(data.totals.triangulation)}</TableCell>
<TableCell colSpan={2} />
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<EmptyState
icon={FileText}
title="Inga EU-försäljningar i perioden"
description="Inga EU-försäljningar med omvänd skattskyldighet under denna period. Periodisk sammanställning behöver inte lämnas in."
>
<a
href="https://www.skatteverket.se/foretag/skatterochavdrag/moms/periodisksammanstallning.html"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 underline underline-offset-4"
>
Läs mer hos Skatteverket
<ExternalLink className="h-3 w-3" />
</a>
</EmptyState>
)}
</>
)}
</div>
)
}
function WarningItem({ warning }: { warning: PsWarning }) {
return (
<div className="flex items-start gap-2">
<span className="text-muted-foreground shrink-0">•</span>
<div className="flex-1">
<div>{warning.message}</div>
{(warning.customerId || warning.invoiceId) && (
<div className="text-xs text-muted-foreground mt-0.5 flex gap-3">
{warning.customerId && (
<Link href={`/customers/${warning.customerId}`} className="underline-offset-4 hover:underline">
Öppna kund
</Link>
)}
{warning.invoiceId && (
<Link href={`/invoices/${warning.invoiceId}`} className="underline-offset-4 hover:underline">
Öppna faktura
</Link>
)}
{warning.amount !== undefined && (
<span className="tabular-nums">{formatCurrency(warning.amount)}</span>
)}
</div>
)}
</div>
</div>
)
}
+1
View File
@@ -43,6 +43,7 @@ const CATEGORIES: ReportCategory[] = [
label: 'Skatt & moms',
items: [
{ value: 'vat-declaration', label: 'Momsdeklaration' },
{ value: 'periodisk-sammanstallning', label: 'Periodisk sammanställning' },
{ value: 'ne-declaration', label: 'NE-bilaga', entityType: 'enskild_firma' },
{ value: 'ink2-declaration', label: 'INK2', entityType: 'aktiebolag' },
],
@@ -56,6 +56,21 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
const result = await response.json()
if (!response.ok) {
// Surface the specific Zod field message when the API sent a
// validation_error envelope — generic "Validation failed" is useless
// to the user.
if (
result?.type === 'validation_error'
&& Array.isArray(result.errors)
&& result.errors.length > 0
) {
const messages = result.errors
.map((e: { message?: string }) => e.message)
.filter((m: unknown): m is string => typeof m === 'string' && m.length > 0)
if (messages.length > 0) {
throw new Error(messages.join(' • '))
}
}
throw new Error(result.error || 'Kunde inte spara inställningar')
}
+61
View File
@@ -107,11 +107,72 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
Enligt beslut från Skatteverket.
</p>
</div>
<div className="max-w-xs space-y-2">
<Label>Period för periodisk sammanställning</Label>
<Select
name="periodisk_sammanstallning_period"
defaultValue={settings.periodisk_sammanstallning_period || 'monthly'}
>
<SelectTrigger>
<SelectValue placeholder="Välj period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Varuförsäljning till EU ska normalt rapporteras månadsvis (35 kap. 2 § SFL).
Kvartal kräver tillstånd från Skatteverket och gäller endast tjänsteförsäljning.
</p>
</div>
</div>
)}
</div>
</section>
{/* Tax contact — required for SKV-filings */}
<section className="border-t border-border/8 pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Kontaktperson för skatteärenden
</h2>
<p className="text-xs text-muted-foreground -mt-2">
Används som avsändare på filer till Skatteverket (periodisk sammanställning m.m.).
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl">
<div className="space-y-2">
<Label htmlFor="tax_contact_name">Namn</Label>
<Input
id="tax_contact_name"
name="tax_contact_name"
defaultValue={settings.tax_contact_name || ''}
placeholder="Anna Andersson"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tax_contact_phone">Telefon</Label>
<Input
id="tax_contact_phone"
name="tax_contact_phone"
defaultValue={settings.tax_contact_phone || ''}
placeholder="08-123 45 67"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="tax_contact_email">E-post</Label>
<Input
id="tax_contact_email"
name="tax_contact_email"
type="email"
defaultValue={settings.tax_contact_email || ''}
placeholder="anna@foretaget.se"
/>
</div>
</div>
</section>
{/* Fiscal year & salaries */}
<section className="border-t border-border/8 pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">