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:
co-authored by
Claude Opus 4.7
parent
ba45b8f661
commit
c8461397c8
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user