chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)

* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 11:51:16 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 18cbc4c30a
commit f266c386f3
359 changed files with 1796 additions and 11199 deletions
@@ -1,63 +0,0 @@
'use client'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Loader2, AlertTriangle } from 'lucide-react'
interface ConfirmDeleteDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title?: string
description?: string
onConfirm: () => void | Promise<void>
isDeleting?: boolean
}
export default function ConfirmDeleteDialog({
open,
onOpenChange,
title = 'Bekräfta borttagning',
description = 'Är du säker på att du vill ta bort detta? Åtgärden kan inte ångras.',
onConfirm,
isDeleting = false,
}: ConfirmDeleteDialogProps) {
const handleConfirm = async () => {
await onConfirm()
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="h-5 w-5 text-destructive" />
</div>
<div>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</div>
</div>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isDeleting}>
Avbryt
</Button>
<Button variant="destructive" onClick={handleConfirm} disabled={isDeleting}>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Tar bort...
</>
) : (
'Ta bort'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,232 +0,0 @@
'use client'
import { useState, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Upload, FileText, Check } from 'lucide-react'
interface CsvImportWizardProps {
targetFields: { key: string; label: string; required?: boolean }[]
defaultMappings?: Record<string, string>
onImport: (rows: Record<string, string>[]) => Promise<void>
className?: string
}
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
const lines = text.split(/\r?\n/).filter(line => line.trim())
if (lines.length === 0) return { headers: [], rows: [] }
const separator = lines[0].includes(';') ? ';' : ','
const headers = lines[0].split(separator).map(h => h.trim().replace(/^"(.*)"$/, '$1'))
const rows = lines.slice(1).map(line =>
line.split(separator).map(cell => cell.trim().replace(/^"(.*)"$/, '$1'))
)
return { headers, rows }
}
export default function CsvImportWizard({
targetFields,
defaultMappings,
onImport,
className,
}: CsvImportWizardProps) {
const [step, setStep] = useState<1 | 2 | 3>(1)
const [headers, setHeaders] = useState<string[]>([])
const [rows, setRows] = useState<string[][]>([])
const [mappings, setMappings] = useState<Record<string, string>>({})
const [isImporting, setIsImporting] = useState(false)
const [importCount, setImportCount] = useState(0)
const [fileName, setFileName] = useState('')
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setFileName(file.name)
const reader = new FileReader()
reader.onload = (ev) => {
const text = ev.target?.result as string
const parsed = parseCsv(text)
setHeaders(parsed.headers)
setRows(parsed.rows)
// Auto-map using defaults
const autoMappings: Record<string, string> = {}
for (const field of targetFields) {
const defaultCsv = defaultMappings?.[field.key]
if (defaultCsv && parsed.headers.includes(defaultCsv)) {
autoMappings[field.key] = defaultCsv
} else {
const match = parsed.headers.find(
h => h.toLowerCase() === field.key.toLowerCase() ||
h.toLowerCase() === field.label.toLowerCase()
)
if (match) autoMappings[field.key] = match
}
}
setMappings(autoMappings)
setStep(2)
}
reader.readAsText(file)
}, [targetFields, defaultMappings])
const handleImport = async () => {
setIsImporting(true)
try {
const mappedRows = rows.map(row => {
const obj: Record<string, string> = {}
for (const [fieldKey, csvCol] of Object.entries(mappings)) {
const colIdx = headers.indexOf(csvCol)
if (colIdx >= 0 && row[colIdx]) {
obj[fieldKey] = row[colIdx]
}
}
return obj
}).filter(row => Object.keys(row).length > 0)
await onImport(mappedRows)
setImportCount(mappedRows.length)
setStep(3)
} finally {
setIsImporting(false)
}
}
const reset = () => {
setStep(1)
setHeaders([])
setRows([])
setMappings({})
setFileName('')
setImportCount(0)
}
const requiredFieldsMapped = targetFields
.filter(f => f.required)
.every(f => mappings[f.key])
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
{step === 1 && <><Upload className="h-4 w-4" /> Steg 1: Välj fil</>}
{step === 2 && <><FileText className="h-4 w-4" /> Steg 2: Kolumnmappning</>}
{step === 3 && <><Check className="h-4 w-4" /> Import klar</>}
</CardTitle>
</CardHeader>
<CardContent>
{step === 1 && (
<div className="space-y-4">
<div className="border-2 border-dashed rounded-lg p-8 text-center">
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-3">
Välj en CSV-fil att importera
</p>
<Label htmlFor="csv-upload" className="cursor-pointer">
<Button variant="outline" size="sm" asChild>
<span>Välj fil</span>
</Button>
</Label>
<input
id="csv-upload"
type="file"
accept=".csv,.txt"
onChange={handleFileSelect}
className="hidden"
/>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{fileName} - {rows.length} rader hittades. Mappa kolumner:
</p>
<div className="space-y-3">
{targetFields.map(field => (
<div key={field.key} className="flex items-center gap-3">
<Label className="w-36 text-sm shrink-0">
{field.label}{field.required && ' *'}
</Label>
<Select
value={mappings[field.key] ?? ''}
onValueChange={(val) => setMappings(prev => ({ ...prev, [field.key]: val }))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Välj kolumn..." />
</SelectTrigger>
<SelectContent>
{headers.map(h => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
{rows.length > 0 && (
<div className="rounded-lg border overflow-auto max-h-48">
<Table>
<TableHeader>
<TableRow>
{headers.map(h => (
/* data-ph-mask: CSV headers are user data */
<TableHead key={h} data-ph-mask="" className="text-xs whitespace-nowrap">{h}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.slice(0, 5).map((row, i) => (
<TableRow key={i}>
{row.map((cell, j) => (
<TableCell key={j} className="text-xs whitespace-nowrap">{cell}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={reset}>
Tillbaka
</Button>
<Button
size="sm"
onClick={handleImport}
disabled={!requiredFieldsMapped || isImporting}
>
{isImporting ? 'Importerar...' : `Importera ${rows.length} rader`}
</Button>
</div>
</div>
)}
{step === 3 && (
<div className="text-center py-4">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-success/10 mb-3">
<Check className="h-6 w-6 text-success" />
</div>
<p className="font-medium">{importCount} rader importerades</p>
<p className="text-sm text-muted-foreground mt-1">
Från {fileName}
</p>
<Button variant="outline" size="sm" onClick={reset} className="mt-4">
Importera fler
</Button>
</div>
)}
</CardContent>
</Card>
)
}
@@ -1,39 +0,0 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface DataEntryFormProps {
title: string
onSubmit: (e: React.FormEvent) => void
submitLabel?: string
isSubmitting?: boolean
children: React.ReactNode
className?: string
}
export default function DataEntryForm({
title,
onSubmit,
submitLabel = 'Spara',
isSubmitting = false,
children,
className,
}: DataEntryFormProps) {
return (
<Card className={cn('', className)}>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
{children}
<Button type="submit" disabled={isSubmitting} size="sm">
{isSubmitting ? 'Sparar...' : submitLabel}
</Button>
</form>
</CardContent>
</Card>
)
}
@@ -1,66 +0,0 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
type Period = 'month' | 'quarter' | 'year' | 'custom'
interface DateRangeFilterProps {
onRangeChange: (start: string, end: string) => void
className?: string
}
export default function DateRangeFilter({ onRangeChange, className }: DateRangeFilterProps) {
const [activePeriod, setActivePeriod] = useState<Period>('month')
const now = new Date()
const handlePeriod = (period: Period) => {
setActivePeriod(period)
const year = now.getFullYear()
const month = now.getMonth()
switch (period) {
case 'month': {
const start = new Date(year, month, 1).toISOString().slice(0, 10)
const end = new Date(year, month + 1, 0).toISOString().slice(0, 10)
onRangeChange(start, end)
break
}
case 'quarter': {
const qStart = Math.floor(month / 3) * 3
const start = new Date(year, qStart, 1).toISOString().slice(0, 10)
const end = new Date(year, qStart + 3, 0).toISOString().slice(0, 10)
onRangeChange(start, end)
break
}
case 'year': {
onRangeChange(`${year}-01-01`, `${year}-12-31`)
break
}
}
}
const periods: { key: Period; label: string }[] = [
{ key: 'month', label: 'Månad' },
{ key: 'quarter', label: 'Kvartal' },
{ key: 'year', label: 'År' },
]
return (
<div className={cn('flex items-center gap-1', className)}>
{periods.map(({ key, label }) => (
<Button
key={key}
variant={activePeriod === key ? 'default' : 'ghost'}
size="sm"
onClick={() => handlePeriod(key)}
className="text-xs"
>
{label}
</Button>
))}
</div>
)
}
@@ -1,62 +0,0 @@
'use client'
import { ReactNode } from 'react'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
interface EditEntryDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
onSave: () => void | Promise<void>
isSaving?: boolean
children: ReactNode
}
export default function EditEntryDialog({
open,
onOpenChange,
title,
description,
onSave,
isSaving = false,
children,
}: EditEntryDialogProps) {
const handleSave = async () => {
await onSave()
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="space-y-4">
{children}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Avbryt
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,23 +0,0 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function ExtensionLoadingSkeleton() {
return (
<div className="space-y-6">
{/* KPI cards skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="rounded-lg border p-6 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-16" />
<Skeleton className="h-3 w-32" />
</div>
))}
</div>
{/* Content skeleton */}
<div className="rounded-lg border p-6 space-y-4">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-40 w-full" />
</div>
</div>
)
}
-34
View File
@@ -1,34 +0,0 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { cn } from '@/lib/utils'
interface KPICardProps {
label: string
value: string | number
suffix?: string
trend?: { value: number; label: string }
className?: string
}
export default function KPICard({ label, value, suffix, trend, className }: KPICardProps) {
return (
<Card className={cn('', className)}>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">{label}</p>
<div className="flex items-baseline gap-1 mt-1">
<span className="text-2xl font-semibold tracking-tight tabular-nums">{value}</span>
{suffix && <span className="text-sm text-muted-foreground">{suffix}</span>}
</div>
{trend && (
<p className={cn(
'text-xs mt-1',
trend.value > 0 ? 'text-success' : trend.value < 0 ? 'text-destructive' : 'text-muted-foreground'
)}>
{trend.value > 0 ? '+' : ''}{trend.value}% {trend.label}
</p>
)}
</CardContent>
</Card>
)
}
@@ -1,49 +0,0 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { FlaskConical, X, Replace } from 'lucide-react'
interface MockDataBannerProps {
importedAt: string | null
onClear: () => void
onReplace: () => void
}
export default function MockDataBanner({ importedAt, onClear, onReplace }: MockDataBannerProps) {
const formatted = importedAt
? new Date(importedAt).toLocaleString('sv-SE', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
: null
return (
<Card className="border-l-4 border-l-warning bg-muted/30">
<CardContent className="pt-4 pb-4">
<div className="flex items-center gap-3">
<FlaskConical className="h-5 w-5 text-attn shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-attn">
Testdata aktivt
</p>
<p className="text-xs text-attn mt-0.5">
Rapporten visar importerad testdata istället för bokföringsdata.
{formatted && <> Importerat {formatted}.</>}
</p>
</div>
<div className="flex gap-1.5 shrink-0">
<Button variant="outline" size="sm" onClick={onReplace} className="h-7 text-xs">
<Replace className="h-3.5 w-3.5 mr-1" />
Ersätt
</Button>
<Button variant="outline" size="sm" onClick={onClear} className="h-7 text-xs">
<X className="h-3.5 w-3.5 mr-1" />
Rensa
</Button>
</div>
</div>
</CardContent>
</Card>
)
}
@@ -1,406 +0,0 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import {
Upload, FileJson, FileSpreadsheet, Download, AlertCircle, Check,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// ── Types ─────────────────────────────────────────────────────
export interface CsvFieldDef {
key: string
label: string
required?: boolean
}
interface MockDataImportDialogProps<T> {
open: boolean
onOpenChange: (open: boolean) => void
csvFields: CsvFieldDef[]
defaultMappings?: Record<string, string>
parseCsvRows: (rows: Record<string, string>[]) => T
validateReport: (data: unknown) => { valid: boolean; error?: string }
templateCsvContent: string
templateFileName: string
onImport: (report: T, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => Promise<void>
}
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
const lines = text.split(/\r?\n/).filter(line => line.trim())
if (lines.length === 0) return { headers: [], rows: [] }
const separator = lines[0].includes(';') ? ';' : ','
const headers = lines[0].split(separator).map(h => h.trim().replace(/^"(.*)"$/, '$1'))
const rows = lines.slice(1).map(line =>
line.split(separator).map(cell => cell.trim().replace(/^"(.*)"$/, '$1'))
)
return { headers, rows }
}
// ── Component ─────────────────────────────────────────────────
type Step = 'upload' | 'map-csv' | 'preview-json' | 'importing'
export default function MockDataImportDialog<T>({
open,
onOpenChange,
csvFields,
defaultMappings,
parseCsvRows,
validateReport,
templateCsvContent,
templateFileName,
onImport,
}: MockDataImportDialogProps<T>) {
const [step, setStep] = useState<Step>('upload')
const [isDragging, setIsDragging] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// CSV state
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
const [csvRows, setCsvRows] = useState<string[][]>([])
const [mappings, setMappings] = useState<Record<string, string>>({})
const [fileName, setFileName] = useState('')
// JSON state
const [jsonReport, setJsonReport] = useState<T | null>(null)
const [jsonSummary, setJsonSummary] = useState('')
const reset = useCallback(() => {
setStep('upload')
setError(null)
setCsvHeaders([])
setCsvRows([])
setMappings({})
setFileName('')
setJsonReport(null)
setJsonSummary('')
setIsDragging(false)
}, [])
const handleOpenChange = useCallback((open: boolean) => {
if (!open) reset()
onOpenChange(open)
}, [onOpenChange, reset])
const processFile = useCallback((file: File) => {
setError(null)
setFileName(file.name)
const reader = new FileReader()
reader.onload = (ev) => {
const text = ev.target?.result as string
if (file.name.endsWith('.json')) {
// JSON path
try {
const parsed = JSON.parse(text)
const validation = validateReport(parsed)
if (!validation.valid) {
setError(validation.error || 'Ogiltig JSON-struktur')
return
}
setJsonReport(parsed as T)
// Build summary
const keys = Object.keys(parsed)
const lines = Array.isArray(parsed.lines) ? parsed.lines.length
: Array.isArray(parsed.receivables) ? parsed.receivables.length
: Array.isArray(parsed.boxes) ? parsed.boxes.length
: null
setJsonSummary(
`${keys.length} fält` + (lines !== null ? `, ${lines} rader` : '')
)
setStep('preview-json')
} catch {
setError('Kunde inte tolka JSON-filen. Kontrollera formatet.')
}
} else {
// CSV path
const parsed = parseCsv(text)
if (parsed.headers.length === 0 || parsed.rows.length === 0) {
setError('Ingen data hittades i CSV-filen.')
return
}
setCsvHeaders(parsed.headers)
setCsvRows(parsed.rows)
// Auto-map columns
const autoMappings: Record<string, string> = {}
for (const field of csvFields) {
const defaultCol = defaultMappings?.[field.key]
if (defaultCol && parsed.headers.includes(defaultCol)) {
autoMappings[field.key] = defaultCol
} else {
const match = parsed.headers.find(
h => h.toLowerCase() === field.key.toLowerCase() ||
h.toLowerCase() === field.label.toLowerCase()
)
if (match) autoMappings[field.key] = match
}
}
setMappings(autoMappings)
setStep('map-csv')
}
}
reader.readAsText(file)
}, [csvFields, defaultMappings, validateReport])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) processFile(file)
}, [processFile])
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) processFile(file)
}, [processFile])
const handleCsvImport = useCallback(async () => {
setStep('importing')
setError(null)
try {
const mappedRows = csvRows.map(row => {
const obj: Record<string, string> = {}
for (const [fieldKey, csvCol] of Object.entries(mappings)) {
const colIdx = csvHeaders.indexOf(csvCol)
if (colIdx >= 0 && row[colIdx]) {
obj[fieldKey] = row[colIdx]
}
}
return obj
}).filter(row => Object.keys(row).length > 0)
const report = parseCsvRows(mappedRows)
await onImport(report, { source: 'csv', fileName, rowCount: mappedRows.length })
handleOpenChange(false)
} catch (e) {
setError(e instanceof Error ? getUserErrorMessage(e) : 'Import misslyckades')
setStep('map-csv')
}
}, [csvRows, csvHeaders, mappings, parseCsvRows, onImport, fileName, handleOpenChange])
const handleJsonImport = useCallback(async () => {
if (!jsonReport) return
setStep('importing')
setError(null)
try {
await onImport(jsonReport, { source: 'json', fileName, rowCount: 0 })
handleOpenChange(false)
} catch (e) {
setError(e instanceof Error ? getUserErrorMessage(e) : 'Import misslyckades')
setStep('preview-json')
}
}, [jsonReport, onImport, fileName, handleOpenChange])
const downloadTemplate = useCallback(() => {
const blob = new Blob([templateCsvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = templateFileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, [templateCsvContent, templateFileName])
const requiredFieldsMapped = csvFields
.filter(f => f.required)
.every(f => mappings[f.key])
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{step === 'upload' && 'Importera testdata'}
{step === 'map-csv' && 'Kolumnmappning'}
{step === 'preview-json' && 'Förhandsgranska JSON'}
{step === 'importing' && 'Importerar...'}
</DialogTitle>
</DialogHeader>
{/* ── Error ─────────────────────────────────────── */}
{error && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-destructive/5 text-destructive text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{error}</span>
</div>
)}
{/* ── Step: Upload ──────────────────────────────── */}
{step === 'upload' && (
<div className="space-y-4">
<div
className={cn(
'border-2 border-dashed rounded-lg p-10 text-center transition-colors',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
)}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
>
<Upload className="h-8 w-8 mx-auto mb-3 text-muted-foreground" />
<p className="text-sm font-medium mb-1">
Dra och släpp en fil här
</p>
<p className="text-xs text-muted-foreground mb-4">
CSV (.csv) eller JSON (.json)
</p>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
Välj fil
</Button>
<input
ref={fileInputRef}
type="file"
accept=".csv,.json"
onChange={handleFileInput}
className="hidden"
/>
</div>
<div className="flex items-center justify-center">
<Button
variant="ghost"
size="sm"
onClick={downloadTemplate}
className="text-xs text-muted-foreground"
>
<Download className="h-3.5 w-3.5 mr-1.5" />
Ladda ner CSV-mall
</Button>
</div>
</div>
)}
{/* ── Step: Map CSV ─────────────────────────────── */}
{step === 'map-csv' && (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileSpreadsheet className="h-4 w-4" />
<span>{fileName}: {csvRows.length} rader</span>
</div>
<div className="space-y-3">
{csvFields.map(field => (
<div key={field.key} className="flex items-center gap-3">
<Label className="w-40 text-sm shrink-0">
{field.label}{field.required && ' *'}
</Label>
<Select
value={mappings[field.key] ?? '___none___'}
onValueChange={(val) => setMappings(prev => ({
...prev,
[field.key]: val === '___none___' ? '' : val,
}))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Välj kolumn..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="___none___">- Välj kolumn -</SelectItem>
{csvHeaders.map(h => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
{/* Preview first 5 rows */}
{csvRows.length > 0 && (
<div className="rounded-lg border overflow-auto max-h-48">
<Table>
<TableHeader>
<TableRow>
{csvHeaders.map(h => (
/* data-ph-mask: CSV headers are user data */
<TableHead key={h} data-ph-mask="" className="text-xs whitespace-nowrap">{h}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{csvRows.slice(0, 5).map((row, i) => (
<TableRow key={i}>
{row.map((cell, j) => (
<TableCell key={j} className="text-xs whitespace-nowrap">{cell}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={reset}>Tillbaka</Button>
<Button
onClick={handleCsvImport}
disabled={!requiredFieldsMapped}
>
Importera {csvRows.length} rader
</Button>
</DialogFooter>
</div>
)}
{/* ── Step: Preview JSON ────────────────────────── */}
{step === 'preview-json' && (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileJson className="h-4 w-4" />
<span>{fileName}</span>
</div>
<div className="flex items-center gap-2 p-3 rounded-lg bg-success/10 text-success text-sm">
<Check className="h-4 w-4 shrink-0" />
<span>Giltig JSON: {jsonSummary}</span>
</div>
<DialogFooter>
<Button variant="outline" onClick={reset}>Tillbaka</Button>
<Button onClick={handleJsonImport}>
Importera testdata
</Button>
</DialogFooter>
</div>
)}
{/* ── Step: Importing ───────────────────────────── */}
{step === 'importing' && (
<div className="py-8 text-center">
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto mb-3" />
<p className="text-sm text-muted-foreground">Importerar testdata...</p>
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -1,74 +0,0 @@
'use client'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { cn } from '@/lib/utils'
interface MonthlyRow {
month: string
value: number
label?: string
}
interface MonthlyTrendTableProps {
rows: MonthlyRow[]
valueLabel?: string
valueSuffix?: string
formatValue?: (v: number) => string
className?: string
}
export default function MonthlyTrendTable({
rows,
valueLabel = 'Belopp',
valueSuffix = 'kr',
formatValue,
className,
}: MonthlyTrendTableProps) {
if (rows.length === 0) {
return (
<div className={cn('rounded-lg border p-6 text-center text-sm text-muted-foreground', className)}>
Ingen data att visa
</div>
)
}
const maxValue = Math.max(...rows.map(r => Math.abs(r.value)), 1)
const fmt = formatValue ?? ((v: number) => v.toLocaleString('sv-SE'))
return (
<div className={cn('rounded-lg border', className)}>
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead className="text-right">{valueLabel}</TableHead>
<TableHead className="w-[40%]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const barWidth = Math.round((Math.abs(row.value) / maxValue) * 100)
return (
<TableRow key={row.month}>
<TableCell className="font-medium">{row.label ?? row.month}</TableCell>
<TableCell className="text-right tabular-nums">
{fmt(row.value)} {valueSuffix}
</TableCell>
<TableCell>
<div className="h-4 w-full rounded-sm bg-muted overflow-hidden">
<div
className="h-full rounded-sm bg-primary/60 transition-all"
style={{ width: `${barWidth}%` }}
/>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
@@ -1,72 +0,0 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Settings } from 'lucide-react'
import { useState } from 'react'
interface SetupField {
key: string
label: string
type?: 'number' | 'text'
placeholder?: string
}
interface SetupPromptProps {
title: string
description: string
fields: SetupField[]
onSave: (values: Record<string, string>) => Promise<void>
}
export default function SetupPrompt({ title, description, fields, onSave }: SetupPromptProps) {
const [values, setValues] = useState<Record<string, string>>({})
const [isSaving, setIsSaving] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSaving(true)
try {
await onSave(values)
} finally {
setIsSaving(false)
}
}
const allFilled = fields.every(f => values[f.key]?.trim())
return (
<div className="flex items-center justify-center py-12">
<Card className="w-full max-w-md">
<CardContent className="pt-6">
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-muted mb-3">
<Settings className="h-6 w-6 text-muted-foreground" />
</div>
<h3>{title}</h3>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{fields.map(field => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`setup-${field.key}`}>{field.label}</Label>
<Input
id={`setup-${field.key}`}
type={field.type ?? 'text'}
placeholder={field.placeholder}
value={values[field.key] ?? ''}
onChange={e => setValues(prev => ({ ...prev, [field.key]: e.target.value }))}
/>
</div>
))}
<Button type="submit" className="w-full" disabled={!allFilled || isSaving}>
{isSaving ? 'Sparar...' : 'Kom igång'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}