Feat/skv integration full (#284)

* feat: add script to import Skatteverket monthly tax tables as fallback TypeScript module

- Implemented a new script `import-tax-tables.ts` to parse fixed-width TXT tax tables from Skatteverket (SKV 434).
- The script generates a TypeScript module for emergency fallback when the Skatteverket open-data API is unavailable.
- Supports command-line argument for specifying the year and handles parsing of B-rows only.
- Outputs a structured TypeScript file containing tax data for specified years.

* feat: gate salary module behind dev-only flag

Temporarily disable the Lön module in production while the feature is
being completed. Sidebar entries ("Löner", "Anställda") still render but
are not clickable and show a "Kommer snart" badge. Middleware redirects
/salary* to / and returns 404 on /api/salary/* so the feature can't be
reached by direct URL. All gates check NODE_ENV === 'development' so
local dev keeps full access for continued development.

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

* feat: refactor bank file import wizard to streamline column mapping and enhance CSV handling

* fix: bump migration timestamp to avoid collision with logos_bucket

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

* feat: enhance AGI generation and salary entry calculations with improved status checks and error handling

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-04-20 21:03:23 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 7a0214c053
commit 11621bb79f
23 changed files with 16227 additions and 340 deletions
+18 -8
View File
@@ -56,6 +56,7 @@ interface NavItem {
group: string
modes?: EntityType[] // If set, only visible for these entity types. If not set, visible to all.
hidden?: boolean // Temporarily hide from sidebar
comingSoon?: boolean // Visible but disabled; shows "Kommer snart" badge
}
// All nav items for sidebar and mobile drawer
@@ -77,9 +78,10 @@ const navItems: NavItem[] = [
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' },
{ href: '/import', label: 'Importera', icon: Upload, group: 'redovisning' },
// Personal
{ href: '/salary', label: 'Löner', icon: HandCoins, group: 'personal', modes: ['aktiebolag'] },
{ href: '/salary/employees', label: 'Anställda', icon: Users, group: 'personal', modes: ['aktiebolag'] },
// Personal — temporarily disabled in production pending feature completion.
// Still clickable in local dev (NODE_ENV === 'development') so we can test.
{ href: '/salary', label: 'Löner', icon: HandCoins, group: 'personal', modes: ['aktiebolag'], comingSoon: process.env.NODE_ENV !== 'development' },
{ href: '/salary/employees', label: 'Anställda', icon: Users, group: 'personal', modes: ['aktiebolag'], comingSoon: process.env.NODE_ENV !== 'development' },
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
{ href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' },
]
@@ -246,7 +248,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{items.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
@@ -259,7 +261,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
<span className="flex-1">{item.label}</span>
{badge !== null && (
{item.comingSoon ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : badge !== null && (
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
{badge > 99 ? '99+' : badge}
</span>
@@ -286,7 +292,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
key={item.href}
className={baseClass}
aria-disabled="true"
title="Lägg till ett företag för att aktivera"
title={item.comingSoon ? 'Kommer snart' : 'Lägg till ett företag för att aktivera'}
>
{content}
</div>
@@ -572,7 +578,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{items.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
@@ -582,7 +588,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm flex-1">{item.label}</span>
{badge !== null && (
{item.comingSoon ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : badge !== null && (
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
{badge > 99 ? '99+' : badge}
</span>
+87 -10
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
@@ -11,6 +11,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
@@ -21,18 +22,17 @@ import {
} from '@/components/ui/table'
import { ArrowLeft, ArrowRight, Columns3 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { getCSVPreview } from '@/lib/import/bank-file/formats/generic-csv'
import type { GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
interface BankFileColumnMappingStepProps {
headers: string[]
previewRows: string[][]
rawFileContent: string
onConfirm: (mapping: GenericCSVColumnMapping) => void
onBack: () => void
}
export default function BankFileColumnMappingStep({
headers,
previewRows,
rawFileContent,
onConfirm,
onBack,
}: BankFileColumnMappingStepProps) {
@@ -42,10 +42,76 @@ export default function BankFileColumnMappingStep({
const [referenceCol, setReferenceCol] = useState<number>(-1)
const [counterpartyCol, setCounterpartyCol] = useState<number>(-1)
const [balanceCol, setBalanceCol] = useState<number>(-1)
const [delimiter, setDelimiter] = useState<string>(',')
// Auto-detect the most likely delimiter by counting field splits on the first line.
// Runs once per file. Users can still override via the dropdown.
const detectedDelimiter = useMemo(() => {
const firstLine = rawFileContent.split(/\r?\n/).find((l) => l.trim() !== '') ?? ''
const candidates: Array<{ d: string; count: number }> = [
{ d: ',', count: getCSVPreview(firstLine, ',', 1)[0]?.length ?? 0 },
{ d: ';', count: getCSVPreview(firstLine, ';', 1)[0]?.length ?? 0 },
{ d: '\t', count: getCSVPreview(firstLine, '\t', 1)[0]?.length ?? 0 },
]
const best = candidates.reduce((a, b) => (b.count > a.count ? b : a))
return best.count > 1 ? best.d : ','
}, [rawFileContent])
const [delimiter, setDelimiter] = useState<string>(detectedDelimiter)
const [decimalSep, setDecimalSep] = useState<',' | '.'>(',')
const [dateFormat, setDateFormat] = useState<string>('YYYY-MM-DD')
// Re-parse headers and preview whenever delimiter or file content changes
const parsedRows = useMemo(
() => getCSVPreview(rawFileContent, delimiter, 10),
[rawFileContent, delimiter]
)
// Auto-detect whether the first row is a header: if any cell on row 0 looks
// like a date (YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY, YYYYMMDD), it's data, not a header.
// Users can still override via the switch.
const DATE_PATTERNS = [/^\d{4}-\d{2}-\d{2}$/, /^\d{2}[./]\d{2}[./]\d{4}$/, /^\d{8}$/]
const detectedHasHeader = useMemo(() => {
const firstRow = parsedRows[0]
if (!firstRow) return true
const hasDateCell = firstRow.some((cell) =>
DATE_PATTERNS.some((re) => re.test(cell.trim()))
)
return !hasDateCell
}, [parsedRows])
const [hasHeaderOverride, setHasHeaderOverride] = useState<boolean | null>(null)
const hasHeader = hasHeaderOverride ?? detectedHasHeader
const columnHeaders = useMemo(() => {
if (hasHeader && parsedRows[0]) return parsedRows[0]
const count = parsedRows[0]?.length ?? 0
return Array.from({ length: count }, (_, i) => `Kolumn ${i + 1}`)
}, [parsedRows, hasHeader])
const dataRows = hasHeader ? parsedRows.slice(1) : parsedRows
// Auto-guess date/description/amount columns from the first data row.
// Only used as initial defaults — user can override any pick.
const AMOUNT_RE = /^-?\d+([.,]\d+)?$/
useEffect(() => {
if (dateCol !== -1 || descCol !== -1 || amountCol !== -1) return
const sample = dataRows[0]
if (!sample || sample.length === 0) return
const dateIdx = sample.findIndex((cell) =>
DATE_PATTERNS.some((re) => re.test(cell.trim()))
)
const amountIdx = sample
.map((cell, i) => ({ i, cell: cell.trim().replace(/\s/g, '') }))
.reverse()
.find(({ cell, i }) => AMOUNT_RE.test(cell) && i !== dateIdx)?.i ?? -1
const descIdx = sample.findIndex((_, i) => i !== dateIdx && i !== amountIdx)
if (dateIdx >= 0) setDateCol(dateIdx)
if (descIdx >= 0) setDescCol(descIdx)
if (amountIdx >= 0) setAmountCol(amountIdx)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataRows])
const isValid = dateCol >= 0 && descCol >= 0 && amountCol >= 0
const handleConfirm = () => {
@@ -58,13 +124,13 @@ export default function BankFileColumnMappingStep({
...(balanceCol >= 0 && { balance: balanceCol }),
delimiter,
decimal_separator: decimalSep,
skip_rows: 1, // Skip header
skip_rows: hasHeader ? 1 : 0,
date_format: dateFormat,
}
onConfirm(mapping)
}
const columnOptions = headers.map((h, i) => ({ label: `${i + 1}: ${h}`, value: i }))
const columnOptions = columnHeaders.map((h, i) => ({ label: `${i + 1}: ${h}`, value: i }))
return (
<div className="space-y-6">
@@ -79,6 +145,17 @@ export default function BankFileColumnMappingStep({
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Header row toggle */}
<div className="flex items-center justify-between rounded-md border p-4">
<div className="space-y-0.5">
<Label htmlFor="has-header">Har filen rubrikrad?</Label>
<p className="text-xs text-muted-foreground">
Slå av om filen saknar rubrikrad och första raden redan innehåller transaktionsdata.
</p>
</div>
<Switch id="has-header" checked={hasHeader} onCheckedChange={setHasHeaderOverride} />
</div>
{/* Delimiter, decimal, and date format settings */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
@@ -254,7 +331,7 @@ export default function BankFileColumnMappingStep({
</Card>
{/* Live preview */}
{isValid && previewRows.length > 1 && (
{isValid && dataRows.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Förhandsgranskning</CardTitle>
@@ -273,7 +350,7 @@ export default function BankFileColumnMappingStep({
</TableRow>
</TableHeader>
<TableBody>
{previewRows.slice(1, 6).map((row, i) => {
{dataRows.slice(0, 5).map((row, i) => {
const amountStr = row[amountCol] || '0'
const amount = decimalSep === ','
? parseFloat(amountStr.replace(/\s/g, '').replace(',', '.'))