@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
vatTreatmentsForAccountClass,
|
||||
type AccountVatTreatment,
|
||||
} from '@/lib/vat/account-vat-treatment'
|
||||
|
||||
interface AccountVatTreatmentSelectProps {
|
||||
value: AccountVatTreatment | 'none'
|
||||
onValueChange: (value: AccountVatTreatment | 'none') => void
|
||||
accountClass: number | null
|
||||
}
|
||||
|
||||
export function AccountVatTreatmentSelect({
|
||||
value,
|
||||
onValueChange,
|
||||
accountClass,
|
||||
}: AccountVatTreatmentSelectProps) {
|
||||
const t = useTranslations('chart_of_accounts')
|
||||
const isRelevant = accountClass === 3 ||
|
||||
(accountClass !== null && accountClass >= 4 && accountClass <= 6)
|
||||
const treatments = accountClass === null ? [] : vatTreatmentsForAccountClass(accountClass)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('vat_treatment_label')}</Label>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(next) => onValueChange(next as AccountVatTreatment | 'none')}
|
||||
disabled={!isRelevant}
|
||||
>
|
||||
<SelectTrigger aria-label={t('vat_treatment_label')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('vat_treatment_none')}</SelectItem>
|
||||
{treatments.map((treatment) => (
|
||||
<SelectItem key={treatment} value={treatment}>
|
||||
{t(`vat_treatment_${treatment}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isRelevant ? t('vat_treatment_help') : t('vat_treatment_not_applicable')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,11 @@ import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
|
||||
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
|
||||
import type { BASAccount } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect'
|
||||
import {
|
||||
defaultRateForVatTreatment,
|
||||
type AccountVatTreatment,
|
||||
} from '@/lib/vat/account-vat-treatment'
|
||||
|
||||
/**
|
||||
* The create path hands back the full row the API inserted. The reactivate
|
||||
@@ -49,6 +54,7 @@ export function AddAccountDialog({
|
||||
// "Standard moms": the moms-sats a booking line defaults to when this konto is
|
||||
// picked. 'none' = no default. SelectItem values are stringified decimals.
|
||||
const [defaultVatRate, setDefaultVatRate] = useState('none')
|
||||
const [defaultVatTreatment, setDefaultVatTreatment] = useState<AccountVatTreatment | 'none'>('none')
|
||||
const [sruCode, setSruCode] = useState('')
|
||||
const [normalBalance, setNormalBalance] = useState<'debit' | 'credit'>('debit')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
@@ -102,6 +108,7 @@ export function AddAccountDialog({
|
||||
normal_balance: normalBalance,
|
||||
description: description || null,
|
||||
default_vat_rate: defaultVatRate === 'none' ? null : parseFloat(defaultVatRate),
|
||||
default_vat_treatment: defaultVatTreatment === 'none' ? null : defaultVatTreatment,
|
||||
sru_code: sruCode || null,
|
||||
}),
|
||||
})
|
||||
@@ -127,6 +134,7 @@ export function AddAccountDialog({
|
||||
setAccountName('')
|
||||
setDescription('')
|
||||
setDefaultVatRate('none')
|
||||
setDefaultVatTreatment('none')
|
||||
setSruCode('')
|
||||
onCreated(createdAccount)
|
||||
onOpenChange(false)
|
||||
@@ -162,6 +170,7 @@ export function AddAccountDialog({
|
||||
setAccountName('')
|
||||
setDescription('')
|
||||
setDefaultVatRate('none')
|
||||
setDefaultVatTreatment('none')
|
||||
setSruCode('')
|
||||
onCreated({ account_number: accountNumber })
|
||||
onOpenChange(false)
|
||||
@@ -260,6 +269,18 @@ export function AddAccountDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AccountVatTreatmentSelect
|
||||
value={defaultVatTreatment}
|
||||
accountClass={derived ? Number(accountNumber.charAt(0)) : null}
|
||||
onValueChange={(treatment) => {
|
||||
setDefaultVatTreatment(treatment)
|
||||
if (treatment !== 'none') {
|
||||
const rate = defaultRateForVatTreatment(treatment, Number(accountNumber.charAt(0)))
|
||||
setDefaultVatRate(rate === null ? 'none' : String(rate))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Standard moms <span className="text-muted-foreground">(valfritt)</span></Label>
|
||||
@@ -275,12 +296,6 @@ export function AddAccountDialog({
|
||||
<SelectItem value="0.06">6 %</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{accountNumber.length === 4 && accountNumber.startsWith('3') && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
På intäktskonton avgör satsen också om kontot räknas som
|
||||
momspliktig försäljning i ruta 05 i momsdeklarationen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>SRU-kod <span className="text-muted-foreground">(valfritt)</span></Label>
|
||||
|
||||
@@ -32,6 +32,11 @@ import {
|
||||
type DimensionRuleType,
|
||||
} from '@/components/dimensions/types'
|
||||
import type { BASAccount } from '@/types'
|
||||
import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect'
|
||||
import {
|
||||
defaultRateForVatTreatment,
|
||||
type AccountVatTreatment,
|
||||
} from '@/lib/vat/account-vat-treatment'
|
||||
|
||||
interface EditAccountDialogProps {
|
||||
open: boolean
|
||||
@@ -65,6 +70,9 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
|
||||
const [defaultVatRate, setDefaultVatRate] = useState(
|
||||
account.default_vat_rate != null ? String(account.default_vat_rate) : 'none',
|
||||
)
|
||||
const [defaultVatTreatment, setDefaultVatTreatment] = useState<AccountVatTreatment | 'none'>(
|
||||
account.default_vat_treatment ?? 'none',
|
||||
)
|
||||
const [sruCode, setSruCode] = useState(account.sru_code || '')
|
||||
const [isActive, setIsActive] = useState(account.is_active)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
@@ -256,6 +264,7 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
|
||||
account_name: accountName,
|
||||
description: description || null,
|
||||
default_vat_rate: defaultVatRate === 'none' ? null : parseFloat(defaultVatRate),
|
||||
default_vat_treatment: defaultVatTreatment === 'none' ? null : defaultVatTreatment,
|
||||
sru_code: sruCode || null,
|
||||
is_active: isActive,
|
||||
}),
|
||||
@@ -313,6 +322,18 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AccountVatTreatmentSelect
|
||||
value={defaultVatTreatment}
|
||||
accountClass={account.account_class}
|
||||
onValueChange={(treatment) => {
|
||||
setDefaultVatTreatment(treatment)
|
||||
if (treatment !== 'none') {
|
||||
const rate = defaultRateForVatTreatment(treatment, account.account_class)
|
||||
setDefaultVatRate(rate === null ? 'none' : String(rate))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Standard moms</Label>
|
||||
@@ -328,12 +349,6 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
|
||||
<SelectItem value="0.06">6 %</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{account.account_class === 3 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
På intäktskonton avgör satsen också om kontot räknas som
|
||||
momspliktig försäljning i ruta 05 i momsdeklarationen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>SRU-kod</Label>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -31,16 +32,26 @@ import {
|
||||
import type { AccountMapping } from '@/lib/import/types'
|
||||
import type { BASAccount } from '@/types'
|
||||
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
|
||||
import {
|
||||
defaultRateForVatTreatment,
|
||||
vatTreatmentsForAccountClass,
|
||||
type AccountVatTreatment,
|
||||
} from '@/lib/vat/account-vat-treatment'
|
||||
|
||||
interface AccountMappingStepProps {
|
||||
mappings: AccountMapping[]
|
||||
basAccounts: BASAccount[]
|
||||
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
|
||||
onVatTreatmentChange?: (
|
||||
sourceAccount: string,
|
||||
treatment: AccountVatTreatment | null,
|
||||
rate: number | null,
|
||||
) => void
|
||||
onContinue: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
type FilterType = 'all' | 'unmapped' | 'low_confidence' | 'manual'
|
||||
type FilterType = 'all' | 'unmapped' | 'vat_review' | 'low_confidence' | 'manual'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
@@ -48,14 +59,17 @@ export default function AccountMappingStep({
|
||||
mappings,
|
||||
basAccounts,
|
||||
onMappingChange,
|
||||
onVatTreatmentChange,
|
||||
onContinue,
|
||||
onBack,
|
||||
}: AccountMappingStepProps) {
|
||||
const t = useTranslations('chart_of_accounts')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
// Default to showing unmapped accounts first (most actionable)
|
||||
const [filter, setFilter] = useState<FilterType>(() => {
|
||||
const hasUnmapped = mappings.some((m) => !m.targetAccount)
|
||||
return hasUnmapped ? 'unmapped' : 'all'
|
||||
const hasVatReview = mappings.some((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed)
|
||||
return hasUnmapped ? 'unmapped' : hasVatReview ? 'vat_review' : 'all'
|
||||
})
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
@@ -71,6 +85,9 @@ export default function AccountMappingStep({
|
||||
case 'low_confidence':
|
||||
result = result.filter((m) => m.targetAccount && m.confidence < 0.7)
|
||||
break
|
||||
case 'vat_review':
|
||||
result = result.filter((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed)
|
||||
break
|
||||
case 'manual':
|
||||
result = result.filter((m) => m.isOverride)
|
||||
break
|
||||
@@ -114,10 +131,11 @@ export default function AccountMappingStep({
|
||||
const unmapped = mappings.filter((m) => !m.targetAccount).length
|
||||
const lowConfidence = mappings.filter((m) => m.targetAccount && m.confidence < 0.7).length
|
||||
const manual = mappings.filter((m) => m.isOverride).length
|
||||
return { unmapped, lowConfidence, manual }
|
||||
const vatReview = mappings.filter((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed).length
|
||||
return { unmapped, lowConfidence, manual, vatReview }
|
||||
}, [mappings])
|
||||
|
||||
const canContinue = stats.unmapped === 0
|
||||
const canContinue = stats.unmapped === 0 && stats.vatReview === 0
|
||||
|
||||
// Group BAS accounts by class for the dropdown
|
||||
const accountsByClass = useMemo(() => {
|
||||
@@ -145,6 +163,14 @@ export default function AccountMappingStep({
|
||||
<CardContent className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<Badge
|
||||
variant={filter === 'vat_review' ? 'default' : stats.vatReview > 0 ? 'secondary' : 'outline'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleFilterChange('vat_review')}
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
{t('vat_review_filter', { count: stats.vatReview })}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={filter === 'unmapped' ? 'destructive' : stats.unmapped > 0 ? 'destructive' : 'secondary'}
|
||||
className="cursor-pointer"
|
||||
@@ -197,6 +223,7 @@ export default function AccountMappingStep({
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Visa alla</SelectItem>
|
||||
<SelectItem value="unmapped">Ej mappade</SelectItem>
|
||||
<SelectItem value="vat_review">{t('vat_review_filter', { count: stats.vatReview })}</SelectItem>
|
||||
<SelectItem value="low_confidence">Osäkra</SelectItem>
|
||||
<SelectItem value="manual">Manuellt satta</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -211,6 +238,7 @@ export default function AccountMappingStep({
|
||||
<TableHead className="w-36">Källkonto</TableHead>
|
||||
<TableHead>Källnamn</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead className="min-w-72">{t('vat_treatment_column')}</TableHead>
|
||||
<TableHead className="w-64">Målkonto</TableHead>
|
||||
<TableHead className="w-24">Konfidens</TableHead>
|
||||
</TableRow>
|
||||
@@ -226,6 +254,78 @@ export default function AccountMappingStep({
|
||||
<TableCell>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{onVatTreatmentChange &&
|
||||
mapping.sourceAccount === mapping.targetAccount &&
|
||||
['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) ? (
|
||||
<div className="flex min-w-72 gap-2">
|
||||
<Select
|
||||
value={mapping.defaultVatTreatment ?? 'none'}
|
||||
onValueChange={(value) => {
|
||||
const treatment = value === 'none'
|
||||
? null
|
||||
: value as AccountVatTreatment
|
||||
const accountClass = Number(mapping.sourceAccount.charAt(0))
|
||||
const rate = treatment
|
||||
? defaultRateForVatTreatment(treatment, accountClass)
|
||||
: null
|
||||
onVatTreatmentChange(mapping.sourceAccount, treatment, rate)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={mapping.requiresVatTreatmentReview ? 'border-warning/60' : ''}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('vat_treatment_none')}</SelectItem>
|
||||
{vatTreatmentsForAccountClass(
|
||||
Number(mapping.sourceAccount.charAt(0)),
|
||||
).map((treatment) => (
|
||||
<SelectItem key={treatment} value={treatment}>
|
||||
{t(`vat_treatment_${treatment}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={mapping.defaultVatRate === null || mapping.defaultVatRate === undefined
|
||||
? 'none'
|
||||
: String(mapping.defaultVatRate)}
|
||||
onValueChange={(value) => onVatTreatmentChange(
|
||||
mapping.sourceAccount,
|
||||
mapping.defaultVatTreatment ?? null,
|
||||
value === 'none' ? null : Number(value),
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="w-24" aria-label={t('vat_rate_label')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('vat_rate_none')}</SelectItem>
|
||||
<SelectItem value="0">0 %</SelectItem>
|
||||
<SelectItem value="0.25">25 %</SelectItem>
|
||||
<SelectItem value="0.12">12 %</SelectItem>
|
||||
<SelectItem value="0.06">6 %</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onVatTreatmentChange(
|
||||
mapping.sourceAccount,
|
||||
mapping.defaultVatTreatment ?? null,
|
||||
mapping.defaultVatRate ?? null,
|
||||
)}
|
||||
>
|
||||
{t('vat_treatment_confirm')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={mapping.targetAccount || 'none'}
|
||||
@@ -275,7 +375,7 @@ export default function AccountMappingStep({
|
||||
))}
|
||||
{paginatedMappings.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
Inga konton matchar filtret
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -35,6 +35,7 @@ interface ImportResultStepProps {
|
||||
* bridge. Absent (failure, oversized file, parse miss) = plain header. */
|
||||
preview?: ImportPreview | null
|
||||
theaterModel?: TheaterModel | null
|
||||
unresolvedVatAccountCount?: number
|
||||
}
|
||||
|
||||
export default function ImportResultStep({
|
||||
@@ -43,6 +44,7 @@ export default function ImportResultStep({
|
||||
onUndo,
|
||||
preview = null,
|
||||
theaterModel = null,
|
||||
unresolvedVatAccountCount = 0,
|
||||
}: ImportResultStepProps) {
|
||||
const t = useTranslations('import')
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
@@ -193,6 +195,23 @@ export default function ImportResultStep({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{result.success && unresolvedVatAccountCount > 0 && (
|
||||
<Card className="border-warning/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertCircle className="h-5 w-5 text-warning" />
|
||||
{t('vat_review_title', { count: unresolvedVatAccountCount })}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('vat_review_description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/chart-of-accounts">{t('vat_review_action')}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Dimensions detected (lossless SIE round-trip, dimensions plan PR5) */}
|
||||
{result.success && result.dimensionsImported && (
|
||||
<Card>
|
||||
|
||||
Reference in New Issue
Block a user