Add/csv import options (#420)

* feat(import): add customer and supplier parsing functionality

- Implemented customer file parsing in `lib/import/customers/parser.ts` with support for Excel and CSV formats.
- Created types for detected customer columns and parsed customer rows in `lib/import/customers/types.ts`.
- Added tests for customer classification logic in `lib/import/shared/__tests__/classify.test.ts`.
- Developed classification functions for customers and suppliers in `lib/import/shared/classify.ts`.
- Introduced shared column utility functions in `lib/import/shared/column-utils.ts`.
- Implemented supplier file parsing in `lib/import/suppliers/parser.ts` with validation for various fields.
- Created types for detected supplier columns and parsed supplier rows in `lib/import/suppliers/types.ts`.
- Added tests for supplier column detection and parsing in `lib/import/suppliers/__tests__/column-detector.test.ts` and `lib/import/suppliers/__tests__/parser.test.ts`.

* fix(labels): update 'Svenskt företag' to 'Svenskt företag eller organisation' for clarity

* feat(import): refactor encoding handling for Swedish files and add tests for character preservation

* feat(recapt): implement clearRecaptIdentity function and integrate into logout flow

* feat(bookkeeping): implement copy functionality and next voucher sequence retrieval

* feat(import): enhance customer and supplier import functionality with normalization and event handling
This commit is contained in:
Mattsson
2026-05-08 15:42:06 +02:00
committed by GitHub
parent 6c5c49f588
commit 81e9dd224e
62 changed files with 4468 additions and 124 deletions
+64 -19
View File
@@ -1,7 +1,8 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
@@ -20,30 +21,43 @@ interface CopyPrefill {
notes: string
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
function readCopyFromParam(): string | null {
if (typeof window === 'undefined') return null
const raw = new URLSearchParams(window.location.search).get('copy_from')
if (!raw) return null
// Guard against path-traversal or other malformed input in the fetch URL.
return UUID_RE.test(raw) ? raw : null
interface NextVoucher {
next: number
series: string
}
type TabValue = 'journal' | 'new-entry' | 'accounts'
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
export default function BookkeepingPage() {
const { toast } = useToast()
const router = useRouter()
const searchParams = useSearchParams()
const copyFromId = useMemo<string | null>(() => {
const raw = searchParams.get('copy_from')
return raw && UUID_RE.test(raw) ? raw : null
}, [searchParams])
const [refreshKey, setRefreshKey] = useState(0)
const [copyFromId] = useState<string | null>(readCopyFromParam)
const [activeTab, setActiveTab] = useState(() =>
copyFromId ? 'new-entry' : 'journal',
)
const [activeTab, setActiveTab] = useState<TabValue>('journal')
const [periodId, setPeriodId] = useState<string | null>(null)
const [copyPrefill, setCopyPrefill] = useState<CopyPrefill | null>(null)
const [isLoadingCopy, setIsLoadingCopy] = useState<boolean>(() => copyFromId !== null)
const [isLoadingCopy, setIsLoadingCopy] = useState(false)
const [nextVoucher, setNextVoucher] = useState<NextVoucher | null>(null)
// React to copy_from in URL: switch tab, fetch source entry, then clean URL.
// useSearchParams keeps this reactive even when navigation happens within the
// same route (e.g. clicking the Kopiera button in the expanded list row),
// which a one-shot useState initializer wouldn't notice.
/* eslint-disable react-hooks/set-state-in-effect -- URL→state sync requires sync setState */
useEffect(() => {
if (!copyFromId) return
setActiveTab('new-entry')
setCopyPrefill(null)
setIsLoadingCopy(true)
fetch(`/api/bookkeeping/journal-entries/${copyFromId}`)
.then((res) => res.json())
.then(({ data, error }: { data?: JournalEntry; error?: string }) => {
@@ -85,10 +99,34 @@ export default function BookkeepingPage() {
})
.finally(() => {
setIsLoadingCopy(false)
// Clean the URL so a page refresh doesn't re-trigger the copy prefill.
window.history.replaceState({}, '', '/bookkeeping')
// Clear copy_from so a refresh doesn't re-trigger and so clicking the
// same entry's Kopiera button again re-fires this effect.
router.replace('/bookkeeping')
})
}, [copyFromId, toast])
}, [copyFromId, toast, router])
/* eslint-enable react-hooks/set-state-in-effect */
// Fetch the next voucher number for today's fiscal period + default series.
// Re-runs after each commit (refreshKey++) so the tab label stays current.
useEffect(() => {
let cancelled = false
fetch('/api/bookkeeping/voucher-sequences/next')
.then((r) => r.json())
.then(({ data }) => {
if (cancelled) return
if (data?.next != null) {
setNextVoucher({ next: data.next, series: data.series })
} else {
setNextVoucher(null)
}
})
.catch(() => {
if (!cancelled) setNextVoucher(null)
})
return () => {
cancelled = true
}
}, [refreshKey])
return (
<div className="space-y-6">
@@ -111,10 +149,17 @@ export default function BookkeepingPage() {
<FiscalYearSelector value={periodId} onChange={setPeriodId} />
)}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
<TabsList>
<TabsTrigger value="journal">Verifikationer</TabsTrigger>
<TabsTrigger value="new-entry">Ny verifikation</TabsTrigger>
<TabsTrigger value="new-entry">
Ny verifikation
{nextVoucher && (
<span className="ml-1 text-muted-foreground tabular-nums">
({nextVoucher.series}{nextVoucher.next})
</span>
)}
</TabsTrigger>
<TabsTrigger value="accounts">Kontoplan</TabsTrigger>
</TabsList>
+1 -1
View File
@@ -32,7 +32,7 @@ import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const customerTypeLabels: Record<CustomerType, string> = {
individual: 'Privatperson',
swedish_business: 'Svenskt företag',
swedish_business: 'Svenskt företag eller organisation',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
}
+1 -1
View File
@@ -19,7 +19,7 @@ import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const customerTypeLabels: Record<CustomerType, string> = {
individual: 'Privatperson',
swedish_business: 'Svenskt företag',
swedish_business: 'Svenskt företag eller organisation',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
}
+623 -13
View File
@@ -7,7 +7,8 @@ import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, Scale } from 'lucide-react'
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet } from 'lucide-react'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
@@ -31,6 +32,23 @@ import OpeningBalancePeriodStep from '@/components/import/OpeningBalancePeriodSt
import OpeningBalanceResultStep from '@/components/import/OpeningBalanceResultStep'
import type { OpeningBalanceParseResult, OpeningBalanceExecuteResult, DetectedColumns } from '@/lib/import/opening-balance/types'
// Register import (customers/suppliers) components
import RegisterUploadStep from '@/components/import/RegisterUploadStep'
import RegisterColumnMappingStep, { type RegisterColumnSpec } from '@/components/import/RegisterColumnMappingStep'
import CustomersEditStep from '@/components/import/CustomersEditStep'
import SuppliersEditStep from '@/components/import/SuppliersEditStep'
import RegisterResultStep, { type RegisterResult } from '@/components/import/RegisterResultStep'
import type {
CustomerImportParseResult,
AnnotatedCustomerRow,
DetectedCustomerColumns,
} from '@/lib/import/customers/types'
import type {
SupplierImportParseResult,
AnnotatedSupplierRow,
DetectedSupplierColumns,
} from '@/lib/import/suppliers/types'
// SIE import components
import SIEUploadStep from '@/components/import/SIEUploadStep'
import SIEPreviewStep from '@/components/import/SIEPreviewStep'
@@ -667,7 +685,7 @@ function SIEImportWizard() {
}
// ============================================================
// Opening Balance Import Wizard
// Opening Balance Flow (entity = "opening_balance" inside CSVDataImportWizard)
// ============================================================
type OpeningBalanceStep = 'upload' | 'column_mapping' | 'edit' | 'period' | 'result'
@@ -680,7 +698,7 @@ const OB_STEP_LABELS: Record<OpeningBalanceStep, string> = {
result: 'Resultat',
}
function OpeningBalanceImportWizard() {
function OpeningBalanceFlow() {
const { toast } = useToast()
const [obStep, setObStep] = useState<OpeningBalanceStep>('upload')
@@ -920,6 +938,598 @@ function OpeningBalanceImportWizard() {
)
}
// ============================================================
// Customers Flow (entity = "customers" inside CSVDataImportWizard)
// ============================================================
type RegisterStep = 'upload' | 'column_mapping' | 'edit' | 'result'
const REGISTER_STEP_LABELS: Record<RegisterStep, string> = {
upload: 'Ladda upp',
column_mapping: 'Kolumnmappning',
edit: 'Granska',
result: 'Resultat',
}
const CUSTOMER_COLUMN_SPECS: RegisterColumnSpec<keyof DetectedCustomerColumns>[] = [
{ key: 'name_col', label: 'Namn', required: true },
{ key: 'org_number_col', label: 'Org-/personnummer', required: false },
{ key: 'customer_type_col', label: 'Kundtyp', required: false },
{ key: 'email_col', label: 'E-post', required: false },
{ key: 'phone_col', label: 'Telefon', required: false },
{ key: 'address_line1_col', label: 'Adress', required: false },
{ key: 'address_line2_col', label: 'Adress rad 2', required: false },
{ key: 'postal_code_col', label: 'Postnummer', required: false },
{ key: 'city_col', label: 'Ort', required: false },
{ key: 'country_col', label: 'Land', required: false },
{ key: 'vat_number_col', label: 'VAT-nummer', required: false },
{ key: 'payment_terms_col', label: 'Betalningsvillkor (dagar)', required: false },
{ key: 'notes_col', label: 'Anteckning', required: false },
]
function columnsToMapping<K extends string>(
cols: { readonly [key: string]: unknown },
specs: RegisterColumnSpec<K>[],
): Record<K, number | null> {
const out = {} as Record<K, number | null>
for (const spec of specs) {
const v = cols[spec.key as string]
out[spec.key] = typeof v === 'number' ? v : null
}
return out
}
function CustomersFlow() {
const { toast } = useToast()
const [step, setStep] = useState<RegisterStep>('upload')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [file, setFile] = useState<File | null>(null)
const [parseResult, setParseResult] = useState<CustomerImportParseResult | null>(null)
const [executeResult, setExecuteResult] = useState<RegisterResult | null>(null)
const needsMapping = parseResult && parseResult.detected_columns.confidence < 0.8
const steps: RegisterStep[] = needsMapping
? ['upload', 'column_mapping', 'edit', 'result']
: ['upload', 'edit', 'result']
const currentStepIndex = steps.indexOf(step)
const progress = ((currentStepIndex + 1) / steps.length) * 100
const handleFileSelect = useCallback(async (selectedFile: File) => {
setError(null)
setIsLoading(true)
setFile(selectedFile)
try {
const formData = new FormData()
formData.append('file', selectedFile)
const res = await fetch('/api/import/customers/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || data.error || 'Kunde inte läsa filen')
return
}
const result = data.data as CustomerImportParseResult
setParseResult(result)
if (result.rows.length === 0) {
setError('Inga giltiga kundrader hittades. Kontrollera att filen innehåller en namnkolumn.')
return
}
toast({
title: 'Fil analyserad',
description: `${result.rows.length} kunder hittades${result.duplicate_count > 0 ? ` (${result.duplicate_count} matchar befintliga)` : ''}`,
})
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
}, [toast])
const handleColumnMappingConfirm = useCallback(async (
mapping: Record<keyof DetectedCustomerColumns, number | null>,
) => {
if (!file) return
setIsLoading(true)
setError(null)
try {
const overrides: DetectedCustomerColumns = {
name_col: mapping.name_col ?? 0,
org_number_col: mapping.org_number_col,
customer_type_col: mapping.customer_type_col,
email_col: mapping.email_col,
phone_col: mapping.phone_col,
address_line1_col: mapping.address_line1_col,
address_line2_col: mapping.address_line2_col,
postal_code_col: mapping.postal_code_col,
city_col: mapping.city_col,
country_col: mapping.country_col,
vat_number_col: mapping.vat_number_col,
payment_terms_col: mapping.payment_terms_col,
notes_col: mapping.notes_col,
confidence: 1,
}
const formData = new FormData()
formData.append('file', file)
formData.append('column_overrides', JSON.stringify(overrides))
const res = await fetch('/api/import/customers/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || 'Kunde inte tolka filen med de valda kolumnerna')
return
}
setParseResult(data.data)
setStep('edit')
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
}, [file])
const handleExecute = useCallback(async (
rows: AnnotatedCustomerRow[],
updateDuplicates: boolean,
) => {
setIsLoading(true)
setError(null)
try {
const res = await fetch('/api/import/customers/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: rows.map(({ duplicate_match: _dup, is_valid: _v, validation_errors: _ve, ...rest }) => rest),
update_duplicates: updateDuplicates,
}),
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || 'Importen misslyckades')
return
}
setExecuteResult(data.data as RegisterResult)
setStep('result')
const r = data.data as RegisterResult
toast({
title: r.success ? 'Kunder importerade' : 'Importen slutfördes med fel',
description: `${r.created} skapade, ${r.updated} uppdaterade, ${r.skipped} hoppade över${r.failed > 0 ? `, ${r.failed} misslyckades` : ''}`,
variant: r.success ? 'default' : 'destructive',
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Importen misslyckades')
} finally {
setIsLoading(false)
}
}, [toast])
const handleNewImport = () => {
setStep('upload')
setFile(null)
setParseResult(null)
setExecuteResult(null)
setError(null)
}
const initialMapping = parseResult
? columnsToMapping<keyof DetectedCustomerColumns>(parseResult.detected_columns as unknown as { [key: string]: unknown }, CUSTOMER_COLUMN_SPECS)
: null
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="sm:hidden text-primary font-medium">
Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]}
</span>
{steps.map((s, i) => (
<span
key={s}
className={cn(
'hidden sm:inline',
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
)}
>
{REGISTER_STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progress} className="h-2" />
</div>
</CardContent>
</Card>
{step === 'upload' && (
<RegisterUploadStep
entity="customers"
onFileSelect={handleFileSelect}
isLoading={isLoading}
error={error}
/>
)}
{step === 'column_mapping' && parseResult && initialMapping && (
<RegisterColumnMappingStep<keyof DetectedCustomerColumns>
headers={parseResult.headers}
previewRows={parseResult.preview_rows}
specs={CUSTOMER_COLUMN_SPECS}
initial={initialMapping}
onConfirm={handleColumnMappingConfirm}
onBack={() => setStep('upload')}
/>
)}
{step === 'edit' && parseResult && (
<CustomersEditStep
rows={parseResult.rows}
onExecute={handleExecute}
onBack={() => setStep(needsMapping ? 'column_mapping' : 'upload')}
isLoading={isLoading}
error={error}
/>
)}
{step === 'result' && executeResult && (
<RegisterResultStep
entity="customers"
result={executeResult}
onNewImport={handleNewImport}
/>
)}
</div>
)
}
// ============================================================
// Suppliers Flow (entity = "suppliers" inside CSVDataImportWizard)
// ============================================================
const SUPPLIER_COLUMN_SPECS: RegisterColumnSpec<keyof DetectedSupplierColumns>[] = [
{ key: 'name_col', label: 'Namn', required: true },
{ key: 'org_number_col', label: 'Org-/personnummer', required: false },
{ key: 'supplier_type_col', label: 'Leverantörstyp', required: false },
{ key: 'email_col', label: 'E-post', required: false },
{ key: 'phone_col', label: 'Telefon', required: false },
{ key: 'address_line1_col', label: 'Adress', required: false },
{ key: 'address_line2_col', label: 'Adress rad 2', required: false },
{ key: 'postal_code_col', label: 'Postnummer', required: false },
{ key: 'city_col', label: 'Ort', required: false },
{ key: 'country_col', label: 'Land', required: false },
{ key: 'vat_number_col', label: 'VAT-nummer', required: false },
{ key: 'bankgiro_col', label: 'Bankgiro', required: false },
{ key: 'plusgiro_col', label: 'Plusgiro', required: false },
{ key: 'bank_account_col', label: 'Bankkonto', required: false },
{ key: 'iban_col', label: 'IBAN', required: false },
{ key: 'bic_col', label: 'BIC/SWIFT', required: false },
{ key: 'payment_terms_col', label: 'Betalningsvillkor (dagar)', required: false },
{ key: 'default_currency_col', label: 'Valuta', required: false },
{ key: 'notes_col', label: 'Anteckning', required: false },
]
function SuppliersFlow() {
const { toast } = useToast()
const [step, setStep] = useState<RegisterStep>('upload')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [file, setFile] = useState<File | null>(null)
const [parseResult, setParseResult] = useState<SupplierImportParseResult | null>(null)
const [executeResult, setExecuteResult] = useState<RegisterResult | null>(null)
const needsMapping = parseResult && parseResult.detected_columns.confidence < 0.8
const steps: RegisterStep[] = needsMapping
? ['upload', 'column_mapping', 'edit', 'result']
: ['upload', 'edit', 'result']
const currentStepIndex = steps.indexOf(step)
const progress = ((currentStepIndex + 1) / steps.length) * 100
const handleFileSelect = useCallback(async (selectedFile: File) => {
setError(null)
setIsLoading(true)
setFile(selectedFile)
try {
const formData = new FormData()
formData.append('file', selectedFile)
const res = await fetch('/api/import/suppliers/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || data.error || 'Kunde inte läsa filen')
return
}
const result = data.data as SupplierImportParseResult
setParseResult(result)
if (result.rows.length === 0) {
setError('Inga giltiga leverantörsrader hittades. Kontrollera att filen innehåller en namnkolumn.')
return
}
toast({
title: 'Fil analyserad',
description: `${result.rows.length} leverantörer hittades${result.duplicate_count > 0 ? ` (${result.duplicate_count} matchar befintliga)` : ''}`,
})
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
}, [toast])
const handleColumnMappingConfirm = useCallback(async (
mapping: Record<keyof DetectedSupplierColumns, number | null>,
) => {
if (!file) return
setIsLoading(true)
setError(null)
try {
const overrides: DetectedSupplierColumns = {
name_col: mapping.name_col ?? 0,
org_number_col: mapping.org_number_col,
supplier_type_col: mapping.supplier_type_col,
email_col: mapping.email_col,
phone_col: mapping.phone_col,
address_line1_col: mapping.address_line1_col,
address_line2_col: mapping.address_line2_col,
postal_code_col: mapping.postal_code_col,
city_col: mapping.city_col,
country_col: mapping.country_col,
vat_number_col: mapping.vat_number_col,
bankgiro_col: mapping.bankgiro_col,
plusgiro_col: mapping.plusgiro_col,
bank_account_col: mapping.bank_account_col,
iban_col: mapping.iban_col,
bic_col: mapping.bic_col,
payment_terms_col: mapping.payment_terms_col,
default_currency_col: mapping.default_currency_col,
notes_col: mapping.notes_col,
confidence: 1,
}
const formData = new FormData()
formData.append('file', file)
formData.append('column_overrides', JSON.stringify(overrides))
const res = await fetch('/api/import/suppliers/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || 'Kunde inte tolka filen')
return
}
setParseResult(data.data)
setStep('edit')
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
}, [file])
const handleExecute = useCallback(async (
rows: AnnotatedSupplierRow[],
updateDuplicates: boolean,
) => {
setIsLoading(true)
setError(null)
try {
const res = await fetch('/api/import/suppliers/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: rows.map(({ duplicate_match: _dup, is_valid: _v, validation_errors: _ve, ...rest }) => rest),
update_duplicates: updateDuplicates,
}),
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || 'Importen misslyckades')
return
}
setExecuteResult(data.data as RegisterResult)
setStep('result')
const r = data.data as RegisterResult
toast({
title: r.success ? 'Leverantörer importerade' : 'Importen slutfördes med fel',
description: `${r.created} skapade, ${r.updated} uppdaterade, ${r.skipped} hoppade över${r.failed > 0 ? `, ${r.failed} misslyckades` : ''}`,
variant: r.success ? 'default' : 'destructive',
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Importen misslyckades')
} finally {
setIsLoading(false)
}
}, [toast])
const handleNewImport = () => {
setStep('upload')
setFile(null)
setParseResult(null)
setExecuteResult(null)
setError(null)
}
const initialMapping = parseResult
? columnsToMapping<keyof DetectedSupplierColumns>(parseResult.detected_columns as unknown as { [key: string]: unknown }, SUPPLIER_COLUMN_SPECS)
: null
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="sm:hidden text-primary font-medium">
Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]}
</span>
{steps.map((s, i) => (
<span
key={s}
className={cn(
'hidden sm:inline',
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
)}
>
{REGISTER_STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progress} className="h-2" />
</div>
</CardContent>
</Card>
{step === 'upload' && (
<RegisterUploadStep
entity="suppliers"
onFileSelect={handleFileSelect}
isLoading={isLoading}
error={error}
/>
)}
{step === 'column_mapping' && parseResult && initialMapping && (
<RegisterColumnMappingStep<keyof DetectedSupplierColumns>
headers={parseResult.headers}
previewRows={parseResult.preview_rows}
specs={SUPPLIER_COLUMN_SPECS}
initial={initialMapping}
onConfirm={handleColumnMappingConfirm}
onBack={() => setStep('upload')}
/>
)}
{step === 'edit' && parseResult && (
<SuppliersEditStep
rows={parseResult.rows}
onExecute={handleExecute}
onBack={() => setStep(needsMapping ? 'column_mapping' : 'upload')}
isLoading={isLoading}
error={error}
/>
)}
{step === 'result' && executeResult && (
<RegisterResultStep
entity="suppliers"
result={executeResult}
onNewImport={handleNewImport}
/>
)}
</div>
)
}
// ============================================================
// CSV/Excel Data Import Wizard — entity selector + sub-flow
// ============================================================
type CSVDataEntity = 'opening_balance' | 'customers' | 'suppliers'
const ENTITY_OPTIONS: { value: CSVDataEntity; label: string }[] = [
{ value: 'opening_balance', label: 'Ingående balanser' },
{ value: 'customers', label: 'Kunder' },
{ value: 'suppliers', label: 'Leverantörer' },
]
function CSVDataImportWizard() {
const [entity, setEntity] = useState<CSVDataEntity | null>('opening_balance')
return (
<div className="space-y-6">
<div className="flex flex-wrap gap-3">
{ENTITY_OPTIONS.map((opt) => {
const selected = entity === opt.value
return (
<div key={opt.value} className="relative">
{selected && (
<svg
aria-hidden
className="pointer-events-none absolute -inset-[3px] h-[calc(100%+6px)] w-[calc(100%+6px)] overflow-visible"
>
<motion.rect
x="1"
y="1"
width="calc(100% - 2px)"
height="calc(100% - 2px)"
rx="8"
ry="8"
fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeDasharray="3 4"
className="text-foreground/45"
animate={{ strokeDashoffset: [0, -14] }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
/>
</svg>
)}
<button
type="button"
onClick={() => setEntity(opt.value)}
aria-pressed={selected}
className={cn(
'relative h-9 rounded-md border px-4 text-sm font-medium transition-colors',
selected
? 'border-foreground bg-foreground text-background'
: 'border-border bg-card text-foreground hover:border-foreground/30 hover:bg-muted',
)}
>
{opt.label}
</button>
</div>
)
})}
</div>
{entity === 'opening_balance' && <OpeningBalanceFlow key="ob-flow" />}
{entity === 'customers' && <CustomersFlow key="cust-flow" />}
{entity === 'suppliers' && <SuppliersFlow key="supp-flow" />}
</div>
)
}
// ============================================================
// PSD2 Bank Connection (inline, from Enable Banking extension)
// ============================================================
@@ -1112,7 +1722,7 @@ function PSD2ConnectWizard() {
// Import Page with Selection Cards
// ============================================================
type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'opening_balance' | 'migration'
type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'csv_data' | 'migration'
export default function ImportPage() {
const { company } = useCompany()
@@ -1146,7 +1756,7 @@ export default function ImportPage() {
setMode('migration')
} else {
const modeParam = searchParams.get('mode')
if (modeParam && ['psd2', 'bank', 'sie', 'opening_balance', 'migration'].includes(modeParam)) {
if (modeParam && ['psd2', 'bank', 'sie', 'csv_data', 'migration'].includes(modeParam)) {
setMode(modeParam as ImportMode)
}
}
@@ -1287,7 +1897,7 @@ export default function ImportPage() {
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
{/* 4. Ingående balanser */}
{/* 4. CSV/Excel-data (ingående balanser, kunder, leverantörer) */}
<div
role="button"
tabIndex={isSandbox ? -1 : 0}
@@ -1298,19 +1908,19 @@ export default function ImportPage() {
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-foreground/15 hover:shadow-[var(--shadow-sm)] active:scale-[0.998]'
)}
onClick={() => { if (!isSandbox) setMode('opening_balance') }}
onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('opening_balance') } }}
onClick={() => { if (!isSandbox) setMode('csv_data') }}
onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('csv_data') } }}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<Scale className="h-[18px] w-[18px] text-foreground/60" />
<FileSpreadsheet className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">Ingående balanser</h3>
<h3 className="text-[15px] font-semibold leading-tight">Importera CSV/Excel-data</h3>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
Importera ingående balanser från en Excel- eller CSV-fil.
Importera ingående balanser, kunder eller leverantörer.
</p>
<div className="flex flex-wrap gap-1.5 mt-2.5">
{['XLSX', 'CSV'].map(fmt => (
{['XLSX', 'CSV', 'Ingående balanser', 'Kunder', 'Leverantörer'].map(fmt => (
<span key={fmt} className="text-[11px] text-muted-foreground/80 bg-muted/80 px-1.5 py-0.5 rounded leading-none">
{fmt}
</span>
@@ -1366,7 +1976,7 @@ export default function ImportPage() {
{mode === 'psd2' && <PSD2ConnectWizard />}
{mode === 'bank' && <BankFileImportWizard />}
{mode === 'sie' && <SIEImportWizard />}
{mode === 'opening_balance' && <OpeningBalanceImportWizard />}
{mode === 'csv_data' && <CSVDataImportWizard />}
{mode === 'migration' && <MigrationWizard userId={userId} />}
</div>
)
@@ -12,6 +12,7 @@ import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings
import { AccountDangerZone } from '@/components/settings/AccountDangerZone'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useSettings } from '@/components/settings/useSettings'
import { clearRecaptIdentity } from '@/lib/recapt'
export default function AccountSettingsPage() {
const router = useRouter()
@@ -24,6 +25,7 @@ export default function AccountSettingsPage() {
useEffect(() => { setMounted(true) }, [])
async function handleLogout() {
clearRecaptIdentity()
await supabase.auth.signOut()
router.push('/login')
}
+1 -1
View File
@@ -16,7 +16,7 @@ import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui
import type { Supplier, SupplierType, CreateSupplierInput, SupplierInvoice } from '@/types'
const supplierTypeLabels: Record<SupplierType, string> = {
swedish_business: 'Svenskt företag',
swedish_business: 'Svenskt företag eller organisation',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
}
+57 -38
View File
@@ -3,12 +3,11 @@
import { useState, useEffect } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Search, Building2, Globe, Lock } from 'lucide-react'
import { Plus, Search, Building2, Lock } from 'lucide-react'
import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link'
import { useCompany } from '@/contexts/CompanyContext'
@@ -16,15 +15,23 @@ import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Supplier, SupplierType, CreateSupplierInput } from '@/types'
const supplierTypeLabels: Record<SupplierType, string> = {
swedish_business: 'Svenskt företag',
swedish_business: 'Svenskt företag eller organisation',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
}
const supplierTypeIcons: Record<SupplierType, React.ElementType> = {
swedish_business: Building2,
eu_business: Globe,
non_eu_business: Globe,
function getPaymentInfo(supplier: Supplier): { label: string; value: string } | null {
if (supplier.bankgiro) return { label: 'BG', value: supplier.bankgiro }
if (supplier.plusgiro) return { label: 'PG', value: supplier.plusgiro }
if (supplier.iban) return { label: 'IBAN', value: supplier.iban }
if (supplier.bank_account) return { label: 'Bankkonto', value: supplier.bank_account }
return null
}
function formatLocation(supplier: Supplier): string | null {
if (supplier.city && supplier.country) return `${supplier.city}, ${supplier.country}`
if (supplier.city) return supplier.city
return null
}
export default function SuppliersPage() {
@@ -150,12 +157,12 @@ export default function SuppliersPage() {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardHeader>
<CardContent className="p-5 space-y-3">
<div className="h-5 bg-muted rounded w-1/2" />
<div className="h-4 bg-muted rounded w-1/3 mt-2" />
</CardHeader>
<CardContent>
<div className="h-4 bg-muted rounded w-full" />
<div className="h-3 bg-muted rounded w-2/3" />
<div className="h-px bg-muted" />
<div className="h-3 bg-muted rounded w-1/2" />
<div className="h-3 bg-muted rounded w-1/3" />
</CardContent>
</Card>
))}
@@ -198,38 +205,50 @@ export default function SuppliersPage() {
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredSuppliers.map((supplier) => {
const Icon = supplierTypeIcons[supplier.supplier_type]
const payment = getPaymentInfo(supplier)
const location = formatLocation(supplier)
return (
<Link key={supplier.id} href={`/suppliers/${supplier.id}`}>
<Card className="hover:border-primary/50 transition-colors cursor-pointer h-full">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{supplier.name}</CardTitle>
<CardDescription>{supplier.email || 'Ingen e-post'}</CardDescription>
</div>
</div>
<Badge variant="secondary">
<Link key={supplier.id} href={`/suppliers/${supplier.id}`} className="group">
<Card className="h-full cursor-pointer transition-all duration-150 hover:border-foreground/20 hover:shadow-sm motion-safe:active:scale-[0.99] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<CardContent className="p-5 flex flex-col h-full">
<div className="space-y-1 mb-4">
<h3 className="text-[15px] font-semibold tracking-tight leading-tight truncate group-hover:text-primary transition-colors">
{supplier.name}
</h3>
<p className="text-xs text-muted-foreground leading-snug">
{supplierTypeLabels[supplier.supplier_type]}
</Badge>
</p>
</div>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground space-y-1">
<dl className="mt-auto space-y-1.5 text-sm border-t pt-3">
{supplier.org_number && (
<p>Org.nr: {supplier.org_number}</p>
<div className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground shrink-0">Org.nr</dt>
<dd className="tabular-nums truncate">{supplier.org_number}</dd>
</div>
)}
{supplier.bankgiro && (
<p>Bankgiro: {supplier.bankgiro}</p>
{payment && (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground shrink-0">{payment.label}</dt>
<dd className="tabular-nums truncate">{payment.value}</dd>
</div>
)}
{supplier.city && (
<p>{supplier.city}, {supplier.country}</p>
{supplier.email && (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground shrink-0">E-post</dt>
<dd className="truncate">{supplier.email}</dd>
</div>
)}
</div>
{location && (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground shrink-0">Plats</dt>
<dd className="truncate">{location}</dd>
</div>
)}
{!supplier.org_number && !payment && !supplier.email && !location && (
<p className="text-xs text-muted-foreground italic">Inga kontaktuppgifter</p>
)}
</dl>
</CardContent>
</Card>
</Link>
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockFrom = vi.fn()
const mockAuth = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn().mockResolvedValue({
from: (...args: unknown[]) => mockFrom(...args),
auth: { getUser: () => mockAuth() },
}),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route'
interface ChainResult {
data?: unknown
error?: unknown
}
function mockChain(result: ChainResult) {
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'lte', 'gte']) {
chain[m] = vi.fn().mockReturnValue(chain)
}
chain.maybeSingle = vi.fn().mockResolvedValue(result)
return chain
}
function mkReq() {
return new Request('http://localhost/api/bookkeeping/voucher-sequences/next')
}
function mkParams() {
return { params: Promise.resolve({}) }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/bookkeeping/voucher-sequences/next', () => {
it('returns 401 when not authenticated', async () => {
mockAuth.mockResolvedValue({ data: { user: null } })
const response = await GET(mkReq(), mkParams())
const body = await response.json()
expect(response.status).toBe(401)
expect(body.error).toBe('Unauthorized')
})
it('returns last_number + 1 when sequence exists', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockFrom.mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return mockChain({ data: { id: 'period-1' }, error: null })
}
if (table === 'company_settings') {
return mockChain({ data: { default_voucher_series: 'A' }, error: null })
}
if (table === 'voucher_sequences') {
return mockChain({ data: { last_number: 57 }, error: null })
}
throw new Error(`Unexpected table: ${table}`)
})
const response = await GET(mkReq(), mkParams())
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual({ next: 58, series: 'A', fiscal_period_id: 'period-1' })
})
it('returns 1 when no sequence row exists yet', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockFrom.mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return mockChain({ data: { id: 'period-1' }, error: null })
}
if (table === 'company_settings') {
return mockChain({ data: null, error: null })
}
if (table === 'voucher_sequences') {
return mockChain({ data: null, error: null })
}
throw new Error(`Unexpected table: ${table}`)
})
const response = await GET(mkReq(), mkParams())
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual({ next: 1, series: 'A', fiscal_period_id: 'period-1' })
})
it('returns next: null when no fiscal period covers today', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockFrom.mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return mockChain({ data: null, error: null })
}
if (table === 'company_settings') {
return mockChain({ data: { default_voucher_series: 'V' }, error: null })
}
throw new Error(`Unexpected table: ${table}`)
})
const response = await GET(mkReq(), mkParams())
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual({ next: null, series: 'V', fiscal_period_id: null })
})
it('honors a non-default voucher series from company settings', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockFrom.mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return mockChain({ data: { id: 'period-2' }, error: null })
}
if (table === 'company_settings') {
return mockChain({ data: { default_voucher_series: 'V' }, error: null })
}
if (table === 'voucher_sequences') {
return mockChain({ data: { last_number: 12 }, error: null })
}
throw new Error(`Unexpected table: ${table}`)
})
const response = await GET(mkReq(), mkParams())
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual({ next: 13, series: 'V', fiscal_period_id: 'period-2' })
})
})
@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
export const GET = withRouteContext(
'voucher_sequence.next',
async (_request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const today = new Date().toISOString().split('T')[0]
const [{ data: period, error: periodError }, { data: settings, error: settingsError }] =
await Promise.all([
supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.lte('period_start', today)
.gte('period_end', today)
.maybeSingle(),
supabase
.from('company_settings')
.select('default_voucher_series')
.eq('company_id', companyId)
.maybeSingle(),
])
if (periodError) {
log.error('fiscal_periods lookup failed', periodError)
return errorResponse(periodError, log, { requestId })
}
if (settingsError) {
log.error('company_settings lookup failed', settingsError)
return errorResponse(settingsError, log, { requestId })
}
const series = settings?.default_voucher_series || 'A'
if (!period) {
return NextResponse.json({ data: { next: null, series, fiscal_period_id: null } })
}
const { data: sequence, error: sequenceError } = await supabase
.from('voucher_sequences')
.select('last_number')
.eq('company_id', companyId)
.eq('fiscal_period_id', period.id)
.eq('voucher_series', series)
.maybeSingle()
if (sequenceError) {
log.error('voucher_sequences lookup failed', sequenceError)
return errorResponse(sequenceError, log, { requestId })
}
const next = (sequence?.last_number ?? 0) + 1
return NextResponse.json({
data: { next, series, fiscal_period_id: period.id },
})
},
)
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server'
import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser'
import { decodeFileContent } from '@/lib/import/bank-file/encoding'
import { decodeFileContent } from '@/lib/import/shared/encoding'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
+208
View File
@@ -0,0 +1,208 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { eventBus } from '@/lib/events'
import { validateBody } from '@/lib/api/validate'
import { CustomerImportExecuteSchema } from '@/lib/api/schemas'
import { normalizeOrgNumber, normalizeEmail } from '@/lib/import/shared/column-utils'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Customer } from '@/types'
import type { CustomerImportExecuteResult } from '@/lib/import/customers/types'
ensureInitialized()
interface ExistingCustomer {
id: string
name: string
org_number: string | null
email: string | null
phone: string | null
address_line1: string | null
address_line2: string | null
postal_code: string | null
city: string | null
country: string
vat_number: string | null
default_payment_terms: number
notes: string | null
customer_type: string
}
/**
* POST /api/import/customers/execute
*
* Imports validated customer rows. Duplicates (matched by org_number or email)
* are either updated (merge — only non-empty file fields overwrite) or skipped
* based on `update_duplicates`.
*/
export const POST = withRouteContext(
'register_import.customers.execute',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
const result = await validateBody(request, CustomerImportExecuteSchema, {
log,
operation: 'register_import.customers.execute',
})
if (!result.success) return result.response
const { rows, update_duplicates } = result.data
const opLog = log.child({ rowCount: rows.length, updateDuplicates: update_duplicates })
if (rows.length === 0) {
return errorResponseFromCode('REG_IMPORT_NO_ROWS', opLog, { requestId })
}
try {
const existingRaw = await fetchAllRows(({ from, to }) =>
supabase
.from('customers')
.select(
'id, name, org_number, email, phone, address_line1, address_line2, ' +
'postal_code, city, country, vat_number, default_payment_terms, notes, ' +
'customer_type',
)
.eq('company_id', companyId)
.range(from, to),
)
const existing = existingRaw as unknown as ExistingCustomer[]
const byOrg = new Map<string, ExistingCustomer>()
const byEmail = new Map<string, ExistingCustomer>()
for (const c of existing) {
const org = normalizeOrgNumber(c.org_number)
if (org) byOrg.set(org, c)
const email = normalizeEmail(c.email)
if (email) byEmail.set(email, c)
}
const created: Customer[] = []
const updated: Customer[] = []
let skipped = 0
const errors: { row_index: number; name: string; reason: string }[] = []
for (const row of rows) {
const orgKey = normalizeOrgNumber(row.org_number)
const emailKey = normalizeEmail(row.email)
const match =
(orgKey && byOrg.get(orgKey)) ||
(emailKey && byEmail.get(emailKey)) ||
null
if (match) {
if (!update_duplicates) {
skipped++
continue
}
// Merge mode: only overwrite fields where the file has a non-empty value.
const merged: Record<string, unknown> = {}
if (row.name) merged.name = row.name
if (row.customer_type) merged.customer_type = row.customer_type
if (row.org_number) merged.org_number = row.org_number
if (row.email) merged.email = row.email
if (row.phone) merged.phone = row.phone
if (row.address_line1) merged.address_line1 = row.address_line1
if (row.address_line2) merged.address_line2 = row.address_line2
if (row.postal_code) merged.postal_code = row.postal_code
if (row.city) merged.city = row.city
if (row.country) merged.country = row.country
if (row.vat_number) merged.vat_number = row.vat_number
if (row.default_payment_terms) merged.default_payment_terms = row.default_payment_terms
if (row.notes) merged.notes = row.notes
if (Object.keys(merged).length === 0) {
skipped++
continue
}
const { data, error } = await supabase
.from('customers')
.update(merged)
.eq('id', match.id)
.eq('company_id', companyId)
.select()
.single()
if (error) {
errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
continue
}
if (data) updated.push(data as Customer)
continue
}
// No match — create.
const { data, error } = await supabase
.from('customers')
.insert({
user_id: user.id,
company_id: companyId,
name: row.name,
customer_type: row.customer_type,
email: row.email,
phone: row.phone,
address_line1: row.address_line1,
address_line2: row.address_line2,
postal_code: row.postal_code,
city: row.city,
country: row.country || 'Sweden',
org_number: row.org_number,
vat_number: row.vat_number,
default_payment_terms: row.default_payment_terms || 30,
notes: row.notes,
})
.select()
.single()
if (error) {
// Treat unique-violation as a soft skip (race with concurrent import).
if (error.code === '23505') {
skipped++
continue
}
errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
continue
}
if (data) {
created.push(data as Customer)
// Track newly inserted org/email so subsequent rows in the same batch
// dedup against them too.
const newOrg = normalizeOrgNumber(data.org_number)
if (newOrg) byOrg.set(newOrg, data as ExistingCustomer)
const newEmail = normalizeEmail(data.email)
if (newEmail) byEmail.set(newEmail, data as ExistingCustomer)
}
}
// Emit events for downstream listeners (non-blocking).
for (const c of created) {
await eventBus.emit({
type: 'customer.created',
payload: { customer: c, companyId: companyId!, userId: user.id },
})
}
const response: CustomerImportExecuteResult = {
success: errors.length === 0,
created: created.length,
updated: updated.length,
skipped,
failed: errors.length,
errors,
}
opLog.info('customer import complete', response)
return NextResponse.json({ data: response })
} catch (err) {
opLog.error('customer import execute failed', err as Error)
return errorResponseFromCode('REG_IMPORT_EXECUTE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
{ requireWrite: true },
)
+120
View File
@@ -0,0 +1,120 @@
import { NextResponse } from 'next/server'
import { parseCustomersFile } from '@/lib/import/customers/parser'
import { normalizeOrgNumber, normalizeEmail } from '@/lib/import/shared/column-utils'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type {
AnnotatedCustomerRow,
CustomerImportParseResult,
DetectedCustomerColumns,
} from '@/lib/import/customers/types'
const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods']
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
/**
* POST /api/import/customers/parse
*
* Accepts an Excel/CSV file via FormData, auto-detects columns, parses rows,
* and annotates each row with any duplicate-match against existing customers.
*/
export const POST = withRouteContext(
'register_import.customers.parse',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const formData = await request.formData()
const file = formData.get('file') as File | null
const columnOverridesRaw = formData.get('column_overrides') as string | null
if (!file) {
return errorResponseFromCode('REG_IMPORT_NO_FILE', log, { requestId })
}
if (file.size > MAX_FILE_SIZE) {
return errorResponseFromCode('REG_IMPORT_FILE_TOO_LARGE', log, {
requestId,
details: { sizeMb: +(file.size / 1024 / 1024).toFixed(1) },
})
}
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
if (!ALLOWED_EXTENSIONS.includes(ext)) {
return errorResponseFromCode('REG_IMPORT_INVALID_FORMAT', log, {
requestId,
details: { extension: ext, allowed: ALLOWED_EXTENSIONS },
})
}
const opLog = log.child({ filename: file.name, sizeBytes: file.size })
let columnOverrides: DetectedCustomerColumns | undefined
if (columnOverridesRaw) {
try {
columnOverrides = JSON.parse(columnOverridesRaw)
} catch {
return errorResponseFromCode('REG_IMPORT_INVALID_COLUMN_OVERRIDES', opLog, { requestId })
}
}
try {
const buffer = await file.arrayBuffer()
const parsed = parseCustomersFile(buffer, file.name, columnOverrides)
// Fetch existing customers for duplicate detection.
const existing = await fetchAllRows(({ from, to }) =>
supabase
.from('customers')
.select('id, name, org_number, email')
.eq('company_id', companyId)
.range(from, to),
)
const byOrg = new Map<string, { id: string; name: string }>()
const byEmail = new Map<string, { id: string; name: string }>()
for (const c of existing) {
const org = normalizeOrgNumber(c.org_number)
if (org) byOrg.set(org, { id: c.id, name: c.name })
const email = normalizeEmail(c.email)
if (email) byEmail.set(email, { id: c.id, name: c.name })
}
let duplicateCount = 0
const annotated: AnnotatedCustomerRow[] = parsed.rows.map((r) => {
const orgKey = normalizeOrgNumber(r.org_number)
const emailKey = normalizeEmail(r.email)
let match: AnnotatedCustomerRow['duplicate_match'] = null
if (orgKey && byOrg.has(orgKey)) {
const e = byOrg.get(orgKey)!
match = { customer_id: e.id, matched_by: 'org_number', existing_name: e.name }
} else if (emailKey && byEmail.has(emailKey)) {
const e = byEmail.get(emailKey)!
match = { customer_id: e.id, matched_by: 'email', existing_name: e.name }
}
if (match) duplicateCount++
return { ...r, duplicate_match: match }
})
const result: CustomerImportParseResult = {
filename: parsed.filename,
sheet_name: parsed.sheet_name,
total_rows: annotated.length,
detected_columns: parsed.detected_columns,
headers: parsed.headers,
preview_rows: parsed.preview_rows,
rows: annotated,
duplicate_count: duplicateCount,
warnings: parsed.warnings,
}
return NextResponse.json({ data: result })
} catch (err) {
opLog.error('customer import parse failed', err as Error)
return errorResponseFromCode('REG_IMPORT_PARSE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
)
+193
View File
@@ -0,0 +1,193 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { eventBus } from '@/lib/events'
import { validateBody } from '@/lib/api/validate'
import { SupplierImportExecuteSchema } from '@/lib/api/schemas'
import { normalizeOrgNumber, normalizeEmail } from '@/lib/import/shared/column-utils'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Supplier } from '@/types'
import type { SupplierImportExecuteResult } from '@/lib/import/suppliers/types'
ensureInitialized()
interface ExistingSupplier {
id: string
name: string
org_number: string | null
email: string | null
}
export const POST = withRouteContext(
'register_import.suppliers.execute',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
const result = await validateBody(request, SupplierImportExecuteSchema, {
log,
operation: 'register_import.suppliers.execute',
})
if (!result.success) return result.response
const { rows, update_duplicates } = result.data
const opLog = log.child({ rowCount: rows.length, updateDuplicates: update_duplicates })
if (rows.length === 0) {
return errorResponseFromCode('REG_IMPORT_NO_ROWS', opLog, { requestId })
}
try {
const existingRaw = await fetchAllRows(({ from, to }) =>
supabase
.from('suppliers')
.select('id, name, org_number, email')
.eq('company_id', companyId)
.range(from, to),
)
const existing = existingRaw as unknown as ExistingSupplier[]
const byOrg = new Map<string, ExistingSupplier>()
const byEmail = new Map<string, ExistingSupplier>()
for (const s of existing) {
const org = normalizeOrgNumber(s.org_number)
if (org) byOrg.set(org, s)
const email = normalizeEmail(s.email)
if (email) byEmail.set(email, s)
}
const created: Supplier[] = []
const updated: Supplier[] = []
let skipped = 0
const errors: { row_index: number; name: string; reason: string }[] = []
for (const row of rows) {
const orgKey = normalizeOrgNumber(row.org_number)
const emailKey = normalizeEmail(row.email)
const match =
(orgKey && byOrg.get(orgKey)) ||
(emailKey && byEmail.get(emailKey)) ||
null
if (match) {
if (!update_duplicates) {
skipped++
continue
}
const merged: Record<string, unknown> = {}
if (row.name) merged.name = row.name
if (row.supplier_type) merged.supplier_type = row.supplier_type
if (row.org_number) merged.org_number = row.org_number
if (row.email) merged.email = row.email
if (row.phone) merged.phone = row.phone
if (row.address_line1) merged.address_line1 = row.address_line1
if (row.address_line2) merged.address_line2 = row.address_line2
if (row.postal_code) merged.postal_code = row.postal_code
if (row.city) merged.city = row.city
if (row.country) merged.country = row.country
if (row.vat_number) merged.vat_number = row.vat_number
if (row.bankgiro) merged.bankgiro = row.bankgiro
if (row.plusgiro) merged.plusgiro = row.plusgiro
if (row.bank_account) merged.bank_account = row.bank_account
if (row.iban) merged.iban = row.iban
if (row.bic) merged.bic = row.bic
if (row.default_payment_terms) merged.default_payment_terms = row.default_payment_terms
if (row.default_currency) merged.default_currency = row.default_currency
if (row.notes) merged.notes = row.notes
if (Object.keys(merged).length === 0) {
skipped++
continue
}
const { data, error } = await supabase
.from('suppliers')
.update(merged)
.eq('id', match.id)
.eq('company_id', companyId)
.select()
.single()
if (error) {
errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
continue
}
if (data) updated.push(data as Supplier)
continue
}
const { data, error } = await supabase
.from('suppliers')
.insert({
user_id: user.id,
company_id: companyId,
name: row.name,
supplier_type: row.supplier_type,
email: row.email,
phone: row.phone,
address_line1: row.address_line1,
address_line2: row.address_line2,
postal_code: row.postal_code,
city: row.city,
country: row.country || 'SE',
org_number: row.org_number,
vat_number: row.vat_number,
bankgiro: row.bankgiro,
plusgiro: row.plusgiro,
bank_account: row.bank_account,
iban: row.iban,
bic: row.bic,
default_payment_terms: row.default_payment_terms || 30,
default_currency: row.default_currency || 'SEK',
notes: row.notes,
})
.select()
.single()
if (error) {
if (error.code === '23505') {
skipped++
continue
}
errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
continue
}
if (data) {
created.push(data as Supplier)
const newOrg = normalizeOrgNumber(data.org_number)
if (newOrg) byOrg.set(newOrg, data as ExistingSupplier)
const newEmail = normalizeEmail(data.email)
if (newEmail) byEmail.set(newEmail, data as ExistingSupplier)
}
}
for (const s of created) {
await eventBus.emit({
type: 'supplier.created',
payload: { supplier: s, companyId, userId: user.id },
})
}
const response: SupplierImportExecuteResult = {
success: errors.length === 0,
created: created.length,
updated: updated.length,
skipped,
failed: errors.length,
errors,
}
opLog.info('supplier import complete', response)
return NextResponse.json({ data: response })
} catch (err) {
opLog.error('supplier import execute failed', err as Error)
return errorResponseFromCode('REG_IMPORT_EXECUTE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
{ requireWrite: true },
)
+113
View File
@@ -0,0 +1,113 @@
import { NextResponse } from 'next/server'
import { parseSuppliersFile } from '@/lib/import/suppliers/parser'
import { normalizeOrgNumber, normalizeEmail } from '@/lib/import/shared/column-utils'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type {
AnnotatedSupplierRow,
SupplierImportParseResult,
DetectedSupplierColumns,
} from '@/lib/import/suppliers/types'
const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods']
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
export const POST = withRouteContext(
'register_import.suppliers.parse',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const formData = await request.formData()
const file = formData.get('file') as File | null
const columnOverridesRaw = formData.get('column_overrides') as string | null
if (!file) {
return errorResponseFromCode('REG_IMPORT_NO_FILE', log, { requestId })
}
if (file.size > MAX_FILE_SIZE) {
return errorResponseFromCode('REG_IMPORT_FILE_TOO_LARGE', log, {
requestId,
details: { sizeMb: +(file.size / 1024 / 1024).toFixed(1) },
})
}
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
if (!ALLOWED_EXTENSIONS.includes(ext)) {
return errorResponseFromCode('REG_IMPORT_INVALID_FORMAT', log, {
requestId,
details: { extension: ext, allowed: ALLOWED_EXTENSIONS },
})
}
const opLog = log.child({ filename: file.name, sizeBytes: file.size })
let columnOverrides: DetectedSupplierColumns | undefined
if (columnOverridesRaw) {
try {
columnOverrides = JSON.parse(columnOverridesRaw)
} catch {
return errorResponseFromCode('REG_IMPORT_INVALID_COLUMN_OVERRIDES', opLog, { requestId })
}
}
try {
const buffer = await file.arrayBuffer()
const parsed = parseSuppliersFile(buffer, file.name, columnOverrides)
const existing = await fetchAllRows(({ from, to }) =>
supabase
.from('suppliers')
.select('id, name, org_number, email')
.eq('company_id', companyId)
.range(from, to),
)
const byOrg = new Map<string, { id: string; name: string }>()
const byEmail = new Map<string, { id: string; name: string }>()
for (const s of existing) {
const org = normalizeOrgNumber(s.org_number)
if (org) byOrg.set(org, { id: s.id, name: s.name })
const email = normalizeEmail(s.email)
if (email) byEmail.set(email, { id: s.id, name: s.name })
}
let duplicateCount = 0
const annotated: AnnotatedSupplierRow[] = parsed.rows.map((r) => {
const orgKey = normalizeOrgNumber(r.org_number)
const emailKey = normalizeEmail(r.email)
let match: AnnotatedSupplierRow['duplicate_match'] = null
if (orgKey && byOrg.has(orgKey)) {
const e = byOrg.get(orgKey)!
match = { supplier_id: e.id, matched_by: 'org_number', existing_name: e.name }
} else if (emailKey && byEmail.has(emailKey)) {
const e = byEmail.get(emailKey)!
match = { supplier_id: e.id, matched_by: 'email', existing_name: e.name }
}
if (match) duplicateCount++
return { ...r, duplicate_match: match }
})
const result: SupplierImportParseResult = {
filename: parsed.filename,
sheet_name: parsed.sheet_name,
total_rows: annotated.length,
detected_columns: parsed.detected_columns,
headers: parsed.headers,
preview_rows: parsed.preview_rows,
rows: annotated,
duplicate_count: duplicateCount,
warnings: parsed.warnings,
}
return NextResponse.json({ data: result })
} catch (err) {
opLog.error('supplier import parse failed', err as Error)
return errorResponseFromCode('REG_IMPORT_PARSE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
)
+10
View File
@@ -1,8 +1,13 @@
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateSupplierSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Supplier } from '@/types'
ensureInitialized()
export const GET = withRouteContext(
'supplier.list',
@@ -79,6 +84,11 @@ export const POST = withRouteContext(
})
}
await eventBus.emit({
type: 'supplier.created',
payload: { supplier: data as Supplier, companyId, userId: user.id },
})
return NextResponse.json({ data })
},
{ requireWrite: true },
+12 -20
View File
@@ -2,21 +2,6 @@
import { useEffect } from 'react'
declare global {
interface Window {
Recapt?: {
session: {
setIdentity: (identity: {
uid: string
email?: string
nickname?: string
fullName?: string
}) => void
}
}
}
}
export function RecaptIdentify({
userId,
email,
@@ -27,16 +12,23 @@ export function RecaptIdentify({
displayName?: string
}) {
useEffect(() => {
let attempts = 0
const maxAttempts = 50
const interval = setInterval(() => {
if (typeof window.Recapt?.session?.setIdentity === 'function') {
window.Recapt.session.setIdentity({
if (typeof window.recapt === 'function') {
window.recapt('identify', {
uid: userId,
email: email,
fullName: displayName,
email,
nickname: displayName,
})
clearInterval(interval)
return
}
}, 500)
attempts++
if (attempts >= maxAttempts) {
clearInterval(interval)
}
}, 100)
return () => clearInterval(interval)
}, [userId, email, displayName])
+12 -1
View File
@@ -2,12 +2,13 @@
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X } from 'lucide-react'
import { ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X, Copy } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { AccountNumber } from '@/components/ui/account-number'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
@@ -30,6 +31,7 @@ interface Props {
}
export default function JournalEntryList({ periodId }: Props) {
const router = useRouter()
const [entries, setEntries] = useState<JournalEntry[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
@@ -463,6 +465,15 @@ export default function JournalEntryList({ periodId }: Props) {
Skapa ändringsverifikation
</Button>
)}
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}
>
<Copy className="mr-2 h-4 w-4" />
Kopiera
</Button>
</div>
</CardContent>
)}
+1 -1
View File
@@ -144,7 +144,7 @@ export default function CustomerForm({
</SelectTrigger>
<SelectContent>
<SelectItem value="individual">Privatperson (Sverige)</SelectItem>
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
<SelectItem value="swedish_business">Svenskt företag eller organisation</SelectItem>
<SelectItem value="eu_business">EU-företag</SelectItem>
<SelectItem value="non_eu_business">Företag utanför EU</SelectItem>
</SelectContent>
+2
View File
@@ -33,6 +33,7 @@ import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { SupportLink } from '@/components/ui/support-link'
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
import { useCompany } from '@/contexts/CompanyContext'
import { clearRecaptIdentity } from '@/lib/recapt'
import type { EntityType } from '@/types'
interface ExtensionNavItem {
@@ -127,6 +128,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
}
const handleLogout = async () => {
clearRecaptIdentity()
await supabase.auth.signOut()
router.push(isSandbox ? '/sandbox' : '/login')
}
+253
View File
@@ -0,0 +1,253 @@
'use client'
import { useMemo, useState, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Trash2, AlertTriangle, Loader2, RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { CustomerType } from '@/types'
import type { AnnotatedCustomerRow } from '@/lib/import/customers/types'
let idCounter = 0
const newId = () => `cust_row_${++idCounter}_${Date.now()}`
interface EditableCustomerRow extends AnnotatedCustomerRow {
id: string
}
interface CustomersEditStepProps {
rows: AnnotatedCustomerRow[]
onExecute: (rows: AnnotatedCustomerRow[], updateDuplicates: boolean) => void
onBack: () => void
isLoading: boolean
error: string | null
}
const TYPE_LABELS: Record<CustomerType, string> = {
individual: 'Privatperson',
swedish_business: 'Svenskt företag eller organisation',
eu_business: 'EU-företag',
non_eu_business: 'Utomeuropeiskt företag',
}
export default function CustomersEditStep({
rows: initialRows,
onExecute,
onBack,
isLoading,
error,
}: CustomersEditStepProps) {
const [rows, setRows] = useState<EditableCustomerRow[]>(() =>
initialRows.map((r) => ({ ...r, id: newId() })),
)
const [updateDuplicates, setUpdateDuplicates] = useState(false)
const liveDuplicateCount = useMemo(
() => rows.filter((r) => r.duplicate_match !== null).length,
[rows],
)
const newCount = rows.length - liveDuplicateCount
const hasErrors = useMemo(
() => rows.some((r) => !r.is_valid),
[rows],
)
const canContinue = rows.length > 0 && !hasErrors && !isLoading
const updateRow = useCallback((id: string, updates: Partial<EditableCustomerRow>) => {
setRows((prev) =>
prev.map((r) => (r.id === id ? { ...r, ...updates } : r)),
)
}, [])
const deleteRow = useCallback((id: string) => {
setRows((prev) => prev.filter((r) => r.id !== id))
}, [])
const handleExecute = () => {
if (!canContinue) return
const stripped: AnnotatedCustomerRow[] = rows.map(({ id: _id, ...rest }) => rest)
onExecute(stripped, updateDuplicates)
}
return (
<Card>
<CardHeader>
<CardTitle>Granska kunder</CardTitle>
<CardDescription>
Kontrollera att uppgifterna stämmer. Du kan justera namn och kundtyp inline,
eller ta bort rader. {newCount} ny{newCount === 1 ? '' : 'a'} kund{newCount === 1 ? '' : 'er'} skapas
{liveDuplicateCount > 0 ? ` och ${liveDuplicateCount} matchar befintliga.` : '.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Duplicate handling banner */}
{liveDuplicateCount > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<RefreshCw className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<div className="flex-1 space-y-2">
<p className="text-sm">
<span className="font-medium">{liveDuplicateCount} rader</span> matchar befintliga
kunder (på orgnummer eller e-post).
</p>
<div className="flex items-center gap-3">
<Switch
id="update-duplicates"
checked={updateDuplicates}
onCheckedChange={setUpdateDuplicates}
/>
<Label htmlFor="update-duplicates" className="text-sm cursor-pointer">
{updateDuplicates
? 'Uppdatera befintliga kunder med ny information'
: 'Hoppa över befintliga kunder'}
</Label>
</div>
{updateDuplicates && (
<p className="text-xs text-muted-foreground">
Endast fält med värden i filen skrivs över. Tomma fält i filen lämnar
befintliga värden orörda.
</p>
)}
</div>
</div>
)}
{/* Table */}
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium">Namn</th>
<th className="px-3 py-2 text-left font-medium w-44">Kundtyp</th>
<th className="px-3 py-2 text-left font-medium w-36">Orgnr</th>
<th className="px-3 py-2 text-left font-medium">E-post</th>
<th className="px-3 py-2 text-left font-medium w-32">Status</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className={cn(
'border-b last:border-0',
!row.is_valid && 'bg-destructive/5',
)}
>
<td className="px-3 py-1.5">
<Input
value={row.name}
onChange={(e) => updateRow(row.id, { name: e.target.value })}
className="h-8"
/>
</td>
<td className="px-3 py-1.5">
<Select
value={row.customer_type}
onValueChange={(v) => updateRow(row.id, { customer_type: v as CustomerType })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(TYPE_LABELS) as CustomerType[]).map((t) => (
<SelectItem key={t} value={t}>
{TYPE_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-3 py-1.5 text-muted-foreground tabular-nums">
{row.org_number || '—'}
</td>
<td className="px-3 py-1.5 text-muted-foreground truncate max-w-xs">
{row.email || '—'}
</td>
<td className="px-3 py-1.5">
<div className="flex items-center gap-1.5">
{!row.is_valid && (
<span
className="text-destructive shrink-0"
title={row.validation_errors.join(', ')}
>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
)}
{row.duplicate_match ? (
<span
className={cn(
'text-[11px] font-medium px-1.5 py-0.5 rounded',
updateDuplicates
? 'bg-warning/15 text-warning'
: 'bg-muted text-muted-foreground',
)}
title={`Matchar ${row.duplicate_match.existing_name} (${row.duplicate_match.matched_by})`}
>
{updateDuplicates ? 'Uppdateras' : 'Hoppas över'}
</span>
) : (
<span className="text-[11px] font-medium px-1.5 py-0.5 rounded bg-success/15 text-success">
Ny
</span>
)}
</div>
</td>
<td className="px-3 py-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => deleteRow(row.id)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{hasErrors && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Vissa rader har valideringsfel (markerade i rött). Åtgärda eller ta bort dem
innan du fortsätter.
</p>
</div>
)}
{error && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={onBack} disabled={isLoading}>
Tillbaka
</Button>
<Button onClick={handleExecute} disabled={!canContinue}>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Importerar...
</>
) : (
`Importera ${rows.length} rad${rows.length === 1 ? '' : 'er'}`
)}
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,125 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
/** A field that can be mapped to a column in the uploaded file. */
export interface RegisterColumnSpec<K extends string> {
key: K
label: string
required: boolean
}
interface RegisterColumnMappingStepProps<K extends string> {
headers: string[]
previewRows: string[][]
specs: RegisterColumnSpec<K>[]
initial: Record<K, number | null>
onConfirm: (mapping: Record<K, number | null>) => void
onBack: () => void
}
export default function RegisterColumnMappingStep<K extends string>({
headers,
previewRows,
specs,
initial,
onConfirm,
onBack,
}: RegisterColumnMappingStepProps<K>) {
const [mapping, setMapping] = useState<Record<K, number | null>>(initial)
const columnOptions = headers.map((h, i) => ({
value: String(i),
label: `${i + 1}: ${h || '(tom)'}`,
}))
const canContinue = specs
.filter((s) => s.required)
.every((s) => mapping[s.key] !== null && mapping[s.key]! >= 0)
return (
<Card>
<CardHeader>
<CardTitle>Kolumnmappning</CardTitle>
<CardDescription>
Vi kunde inte automatiskt identifiera alla kolumner. Ange vilka kolumner i din fil
som motsvarar respektive fält. Lämna tomt för fält som inte finns.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{specs.map((spec) => (
<div key={spec.key} className="space-y-2">
<Label>
{spec.label}
{spec.required && ' *'}
</Label>
<Select
value={mapping[spec.key] !== null ? String(mapping[spec.key]) : 'none'}
onValueChange={(v) =>
setMapping((prev) => ({
...prev,
[spec.key]: v === 'none' ? null : Number(v),
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{!spec.required && <SelectItem value="none">(ingen)</SelectItem>}
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
{previewRows.length > 0 && (
<div className="space-y-2">
<Label className="text-muted-foreground">Förhandsgranskning (5 första raderna)</Label>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
{headers.map((h, i) => (
<th key={i} className="px-3 py-2 text-left font-medium whitespace-nowrap">
{h || `Kolumn ${i + 1}`}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.slice(0, 5).map((row, ri) => (
<tr key={ri} className="border-b last:border-0">
{row.map((cell, ci) => (
<td key={ci} className="px-3 py-1.5 whitespace-nowrap">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex justify-between">
<Button variant="ghost" onClick={onBack}>Tillbaka</Button>
<Button onClick={() => onConfirm(mapping)} disabled={!canContinue}>
Fortsätt
</Button>
</div>
</CardContent>
</Card>
)
}
+135
View File
@@ -0,0 +1,135 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { CheckCircle2, XCircle, ArrowRight, AlertTriangle } from 'lucide-react'
import Link from 'next/link'
export type RegisterResult = {
success: boolean
created: number
updated: number
skipped: number
failed: number
errors: { row_index: number; name: string; reason: string }[]
}
interface RegisterResultStepProps {
entity: 'customers' | 'suppliers'
result: RegisterResult
onNewImport: () => void
}
const ENTITY_COPY = {
customers: {
successTitle: 'Kunder importerade',
failTitle: 'Importen misslyckades',
listLabel: 'Visa alla kunder',
listHref: '/customers',
},
suppliers: {
successTitle: 'Leverantörer importerade',
failTitle: 'Importen misslyckades',
listLabel: 'Visa alla leverantörer',
listHref: '/suppliers',
},
} as const
export default function RegisterResultStep({
entity,
result,
onNewImport,
}: RegisterResultStepProps) {
const copy = ENTITY_COPY[entity]
const totalProcessed = result.created + result.updated + result.skipped + result.failed
const isPartial = result.failed > 0 && result.created + result.updated > 0
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
{result.success ? (
<CheckCircle2 className="h-6 w-6 text-success" />
) : isPartial ? (
<AlertTriangle className="h-6 w-6 text-warning" />
) : (
<XCircle className="h-6 w-6 text-destructive" />
)}
<CardTitle>
{result.success
? copy.successTitle
: isPartial
? 'Import slutförd med fel'
: copy.failTitle}
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Stat label="Skapade" value={result.created} accent="success" />
<Stat label="Uppdaterade" value={result.updated} accent="warning" />
<Stat label="Hoppades över" value={result.skipped} />
<Stat label="Misslyckades" value={result.failed} accent={result.failed > 0 ? 'destructive' : 'muted'} />
</div>
{totalProcessed === 0 && (
<p className="text-sm text-muted-foreground">Inga rader bearbetades.</p>
)}
{/* Errors */}
{result.errors.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Rader som inte kunde importeras</h4>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 max-h-60 overflow-y-auto">
<ul className="divide-y divide-destructive/20">
{result.errors.map((e, i) => (
<li key={i} className="px-3 py-2 text-sm">
<span className="font-medium">Rad {e.row_index}: {e.name}</span>
<span className="text-muted-foreground"> — {e.reason}</span>
</li>
))}
</ul>
</div>
</div>
)}
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link href={copy.listHref}>
{copy.listLabel}
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
<Button variant="ghost" onClick={onNewImport}>Ny import</Button>
</div>
</CardContent>
</Card>
)
}
function Stat({
label,
value,
accent,
}: {
label: string
value: number
accent?: 'success' | 'warning' | 'destructive' | 'muted'
}) {
return (
<div className="rounded-lg border bg-muted/30 p-4 text-center">
<p
className={
accent === 'success' ? 'text-2xl font-semibold tabular-nums text-success' :
accent === 'warning' ? 'text-2xl font-semibold tabular-nums text-warning' :
accent === 'destructive' ? 'text-2xl font-semibold tabular-nums text-destructive' :
'text-2xl font-semibold tabular-nums'
}
>
{value}
</p>
<p className="text-sm text-muted-foreground mt-1">{label}</p>
</div>
)
}
+123
View File
@@ -0,0 +1,123 @@
'use client'
import { useCallback, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Upload, FileSpreadsheet, AlertCircle, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
export type RegisterEntity = 'customers' | 'suppliers'
interface RegisterUploadStepProps {
entity: RegisterEntity
onFileSelect: (file: File) => void
isLoading: boolean
error: string | null
}
const COPY: Record<RegisterEntity, { title: string; description: string; hint: string }> = {
customers: {
title: 'Ladda upp fil med kunder',
description:
'Ladda upp en Excel- eller CSV-fil med ditt kundregister. Filen bör innehålla minst en kolumn med kundnamn.',
hint:
'Vanliga kolumner identifieras automatiskt — t.ex. "Namn", "Orgnr", "E-post", "Telefon", "Adress", "Postort".',
},
suppliers: {
title: 'Ladda upp fil med leverantörer',
description:
'Ladda upp en Excel- eller CSV-fil med ditt leverantörsregister. Filen bör innehålla minst en kolumn med leverantörsnamn.',
hint:
'Vanliga kolumner identifieras automatiskt — t.ex. "Namn", "Orgnr", "Bankgiro", "Plusgiro", "IBAN", "E-post".',
},
}
export default function RegisterUploadStep({
entity,
onFileSelect,
isLoading,
error,
}: RegisterUploadStepProps) {
const [isDragging, setIsDragging] = useState(false)
const copy = COPY[entity]
const handleFile = useCallback((file: File) => {
const ext = file.name.split('.').pop()?.toLowerCase()
if (!ext || !['xlsx', 'xls', 'csv', 'ods'].includes(ext)) return
onFileSelect(file)
}, [onFileSelect])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFile(file)
}, [handleFile])
return (
<Card>
<CardHeader>
<CardTitle>{copy.title}</CardTitle>
<CardDescription>{copy.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-10 transition-colors',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/20 hover:border-muted-foreground/40',
isLoading && 'pointer-events-none opacity-60',
)}
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
>
{isLoading ? (
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Läser fil och identifierar kolumner...</p>
</div>
) : (
<>
<Upload className="h-8 w-8 text-muted-foreground/50 mb-3" />
<p className="text-sm font-medium">Dra och släpp din fil här</p>
<p className="text-sm text-muted-foreground mt-1">eller</p>
<label>
<input
type="file"
accept=".xlsx,.xls,.csv,.ods"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
e.target.value = ''
}}
/>
<Button variant="outline" size="sm" className="mt-2" asChild>
<span>Välj fil</span>
</Button>
</label>
<p className="text-xs text-muted-foreground mt-3">XLSX, XLS, CSV, ODS — max 10 MB</p>
</>
)}
</div>
{error && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/50 px-4 py-3">
<FileSpreadsheet className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="text-sm text-muted-foreground space-y-1">
<p className="font-medium text-foreground">Filformat</p>
<p>{copy.hint}</p>
</div>
</div>
</CardContent>
</Card>
)
}
+241
View File
@@ -0,0 +1,241 @@
'use client'
import { useMemo, useState, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Trash2, AlertTriangle, Loader2, RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { SupplierType } from '@/types'
import type { AnnotatedSupplierRow } from '@/lib/import/suppliers/types'
let idCounter = 0
const newId = () => `supp_row_${++idCounter}_${Date.now()}`
interface EditableSupplierRow extends AnnotatedSupplierRow {
id: string
}
interface SuppliersEditStepProps {
rows: AnnotatedSupplierRow[]
onExecute: (rows: AnnotatedSupplierRow[], updateDuplicates: boolean) => void
onBack: () => void
isLoading: boolean
error: string | null
}
const TYPE_LABELS: Record<SupplierType, string> = {
swedish_business: 'Svenskt företag eller organisation',
eu_business: 'EU-företag',
non_eu_business: 'Utomeuropeiskt företag',
}
export default function SuppliersEditStep({
rows: initialRows,
onExecute,
onBack,
isLoading,
error,
}: SuppliersEditStepProps) {
const [rows, setRows] = useState<EditableSupplierRow[]>(() =>
initialRows.map((r) => ({ ...r, id: newId() })),
)
const [updateDuplicates, setUpdateDuplicates] = useState(false)
const liveDuplicateCount = useMemo(
() => rows.filter((r) => r.duplicate_match !== null).length,
[rows],
)
const newCount = rows.length - liveDuplicateCount
const hasErrors = useMemo(() => rows.some((r) => !r.is_valid), [rows])
const canContinue = rows.length > 0 && !hasErrors && !isLoading
const updateRow = useCallback((id: string, updates: Partial<EditableSupplierRow>) => {
setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...updates } : r)))
}, [])
const deleteRow = useCallback((id: string) => {
setRows((prev) => prev.filter((r) => r.id !== id))
}, [])
const handleExecute = () => {
if (!canContinue) return
const stripped: AnnotatedSupplierRow[] = rows.map(({ id: _id, ...rest }) => rest)
onExecute(stripped, updateDuplicates)
}
return (
<Card>
<CardHeader>
<CardTitle>Granska leverantörer</CardTitle>
<CardDescription>
Kontrollera att uppgifterna stämmer. {newCount} ny{newCount === 1 ? '' : 'a'} leverantör{newCount === 1 ? '' : 'er'} skapas
{liveDuplicateCount > 0 ? ` och ${liveDuplicateCount} matchar befintliga.` : '.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{liveDuplicateCount > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<RefreshCw className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<div className="flex-1 space-y-2">
<p className="text-sm">
<span className="font-medium">{liveDuplicateCount} rader</span> matchar befintliga
leverantörer (på orgnummer eller e-post).
</p>
<div className="flex items-center gap-3">
<Switch
id="update-duplicates-supp"
checked={updateDuplicates}
onCheckedChange={setUpdateDuplicates}
/>
<Label htmlFor="update-duplicates-supp" className="text-sm cursor-pointer">
{updateDuplicates
? 'Uppdatera befintliga leverantörer med ny information'
: 'Hoppa över befintliga leverantörer'}
</Label>
</div>
{updateDuplicates && (
<p className="text-xs text-muted-foreground">
Endast fält med värden i filen skrivs över. Tomma fält i filen lämnar
befintliga värden orörda.
</p>
)}
</div>
</div>
)}
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium">Namn</th>
<th className="px-3 py-2 text-left font-medium w-44">Typ</th>
<th className="px-3 py-2 text-left font-medium w-36">Orgnr</th>
<th className="px-3 py-2 text-left font-medium w-32">Bankgiro/IBAN</th>
<th className="px-3 py-2 text-left font-medium w-32">Status</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className={cn(
'border-b last:border-0',
!row.is_valid && 'bg-destructive/5',
)}
>
<td className="px-3 py-1.5">
<Input
value={row.name}
onChange={(e) => updateRow(row.id, { name: e.target.value })}
className="h-8"
/>
</td>
<td className="px-3 py-1.5">
<Select
value={row.supplier_type}
onValueChange={(v) => updateRow(row.id, { supplier_type: v as SupplierType })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(TYPE_LABELS) as SupplierType[]).map((t) => (
<SelectItem key={t} value={t}>
{TYPE_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-3 py-1.5 text-muted-foreground tabular-nums">
{row.org_number || '—'}
</td>
<td className="px-3 py-1.5 text-muted-foreground tabular-nums truncate max-w-[10rem]">
{row.bankgiro || row.plusgiro || row.iban || '—'}
</td>
<td className="px-3 py-1.5">
<div className="flex items-center gap-1.5">
{!row.is_valid && (
<span
className="text-destructive shrink-0"
title={row.validation_errors.join(', ')}
>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
)}
{row.duplicate_match ? (
<span
className={cn(
'text-[11px] font-medium px-1.5 py-0.5 rounded',
updateDuplicates
? 'bg-warning/15 text-warning'
: 'bg-muted text-muted-foreground',
)}
title={`Matchar ${row.duplicate_match.existing_name} (${row.duplicate_match.matched_by})`}
>
{updateDuplicates ? 'Uppdateras' : 'Hoppas över'}
</span>
) : (
<span className="text-[11px] font-medium px-1.5 py-0.5 rounded bg-success/15 text-success">
Ny
</span>
)}
</div>
</td>
<td className="px-3 py-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => deleteRow(row.id)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{hasErrors && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Vissa rader har valideringsfel (markerade i rött). Åtgärda eller ta bort dem
innan du fortsätter.
</p>
</div>
)}
{error && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={onBack} disabled={isLoading}>Tillbaka</Button>
<Button onClick={handleExecute} disabled={!canContinue}>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Importerar...
</>
) : (
`Importera ${rows.length} rad${rows.length === 1 ? '' : 'er'}`
)}
</Button>
</div>
</CardContent>
</Card>
)
}
+1 -1
View File
@@ -42,7 +42,7 @@ export function InvoiceReviewContent({
}: InvoiceReviewContentProps) {
const customerTypeLabel: Record<string, string> = {
individual: 'Privatperson',
swedish_business: 'Svenskt företag',
swedish_business: 'Svenskt företag eller organisation',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
}
+1 -1
View File
@@ -98,7 +98,7 @@ export default function SupplierForm({
<SelectValue placeholder="Välj typ" />
</SelectTrigger>
<SelectContent>
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
<SelectItem value="swedish_business">Svenskt företag eller organisation</SelectItem>
<SelectItem value="eu_business">EU-företag</SelectItem>
<SelectItem value="non_eu_business">Företag utanför EU</SelectItem>
</SelectContent>
+54
View File
@@ -607,6 +607,60 @@ export const OpeningBalanceExecuteSchema = z.object({
})).min(2, 'At least two lines are required for double-entry'),
})
// ============================================================
// Register import schemas (customers, suppliers)
// ============================================================
const ImportedCustomerRowSchema = z.object({
row_index: z.number().int(),
name: z.string().min(1),
customer_type: CustomerTypeSchema,
org_number: z.string().nullable(),
email: z.string().nullable(),
phone: z.string().nullable(),
address_line1: z.string().nullable(),
address_line2: z.string().nullable(),
postal_code: z.string().nullable(),
city: z.string().nullable(),
country: z.string(),
vat_number: z.string().nullable(),
default_payment_terms: z.number().int().min(0).max(365),
notes: z.string().nullable(),
})
export const CustomerImportExecuteSchema = z.object({
rows: z.array(ImportedCustomerRowSchema).min(1, 'At least one row is required'),
update_duplicates: z.boolean(),
})
const ImportedSupplierRowSchema = z.object({
row_index: z.number().int(),
name: z.string().min(1),
supplier_type: SupplierTypeSchema,
org_number: z.string().nullable(),
email: z.string().nullable(),
phone: z.string().nullable(),
address_line1: z.string().nullable(),
address_line2: z.string().nullable(),
postal_code: z.string().nullable(),
city: z.string().nullable(),
country: z.string(),
vat_number: z.string().nullable(),
bankgiro: z.string().nullable(),
plusgiro: z.string().nullable(),
bank_account: z.string().nullable(),
iban: z.string().nullable(),
bic: z.string().nullable(),
default_payment_terms: z.number().int().min(0).max(365),
default_currency: z.string(),
notes: z.string().nullable(),
})
export const SupplierImportExecuteSchema = z.object({
rows: z.array(ImportedSupplierRowSchema).min(1, 'At least one row is required'),
update_duplicates: z.boolean(),
})
// ============================================================
// Salary schemas
// ============================================================
+39
View File
@@ -845,6 +845,44 @@ const OPENING_BALANCE_IMPORT: Record<string, StructuredErrorEntry> = {
},
}
const REGISTER_IMPORT: Record<string, StructuredErrorEntry> = {
REG_IMPORT_NO_FILE: {
httpStatus: 400,
message_sv: 'Ingen fil bifogad.',
message_en: 'No file attached.',
},
REG_IMPORT_FILE_TOO_LARGE: {
httpStatus: 400,
message_sv: 'Filen är för stor. Maxstorlek är 10 MB.',
message_en: 'File exceeds the 10 MB size limit.',
},
REG_IMPORT_INVALID_FORMAT: {
httpStatus: 400,
message_sv: 'Filformatet stöds inte. Tillåtna format: .xlsx, .xls, .csv, .ods.',
message_en: 'Unsupported file format.',
},
REG_IMPORT_INVALID_COLUMN_OVERRIDES: {
httpStatus: 400,
message_sv: 'Ogiltig kolumnmappning.',
message_en: 'Invalid column overrides JSON.',
},
REG_IMPORT_PARSE_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte tolka filen.',
message_en: 'Failed to parse the register file.',
},
REG_IMPORT_NO_ROWS: {
httpStatus: 400,
message_sv: 'Inga giltiga rader hittades i filen.',
message_en: 'No valid rows found in the file.',
},
REG_IMPORT_EXECUTE_FAILED: {
httpStatus: 500,
message_sv: 'Importen misslyckades.',
message_en: 'Register import failed.',
},
}
// ─────────────────────────────────────────────────────────────────
// Wave 3 tail: provider migration extension codes
// ─────────────────────────────────────────────────────────────────
@@ -1238,6 +1276,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...SIE_IMPORT,
...BANK_FILE,
...OPENING_BALANCE_IMPORT,
...REGISTER_IMPORT,
...PROVIDER_MIGRATION,
...DOCUMENT,
...CUSTOMER,
+2 -1
View File
@@ -24,6 +24,7 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [
'period.locked',
'period.year_closed',
'customer.created',
'supplier.created',
'receipt.matched',
'receipt.confirmed',
'supplier_invoice.registered',
@@ -52,7 +53,7 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [
function extractEntityId(payload: Record<string, unknown>): string | null {
// Try common entity shapes in priority order
const entityKeys = [
'entry', 'invoice', 'transaction', 'customer', 'receipt',
'entry', 'invoice', 'transaction', 'customer', 'supplier', 'receipt',
'supplierInvoice', 'creditNote', 'period', 'document', 'inboxItem',
] as const
+3
View File
@@ -3,6 +3,7 @@ import type {
Invoice,
Transaction,
Customer,
Supplier,
FiscalPeriod,
DocumentAttachment,
Receipt,
@@ -40,6 +41,8 @@ export type CoreEvent =
| { type: 'period.year_closed'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
// Customers
| { type: 'customer.created'; payload: { customer: Customer; userId: string; companyId: string } }
// Suppliers
| { type: 'supplier.created'; payload: { supplier: Supplier; userId: string; companyId: string } }
// Receipts
| { type: 'receipt.extracted'; payload: {
receipt: Receipt;
+1 -1
View File
@@ -14,7 +14,7 @@
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { prepareContent } from '../../shared/encoding'
export const camt053Format: BankFileFormat = {
id: 'camt053',
+1 -1
View File
@@ -6,7 +6,7 @@
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue, GenericCSVColumnMapping } from '../types'
import { prepareContent } from '../encoding'
import { prepareContent } from '../../shared/encoding'
import { parseCSVLine } from './nordea'
import { normalizeDate } from '../date-utils'
@@ -11,7 +11,7 @@
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
function parseCommaDecimal(value: string): number {
+1 -1
View File
@@ -13,7 +13,7 @@
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
function parseCommaDecimal(value: string): number {
@@ -13,7 +13,7 @@
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
import { parseCSVLine } from './nordea'
+1 -1
View File
@@ -14,7 +14,7 @@
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
import { parseCSVLine } from './nordea'
@@ -21,7 +21,7 @@
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
function parseCommaDecimal(value: string): number {
+1 -1
View File
@@ -12,7 +12,7 @@
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
function parseCommaDecimal(value: string): number {
+1 -1
View File
@@ -9,7 +9,7 @@
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
function parseCommaDecimal(value: string): number {
+1 -1
View File
@@ -12,7 +12,7 @@
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
function parseCommaDecimal(value: string): number {
+1 -1
View File
@@ -17,7 +17,7 @@
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
import { parseCSVLine } from './nordea'
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { detectCustomerColumns } from '../column-detector'
describe('detectCustomerColumns', () => {
it('detects Swedish customer register headers', () => {
const headers = ['Namn', 'Orgnr', 'E-post', 'Telefon', 'Adress', 'Postnr', 'Ort']
const result = detectCustomerColumns(headers)
expect(result.name_col).toBe(0)
expect(result.org_number_col).toBe(1)
expect(result.email_col).toBe(2)
expect(result.phone_col).toBe(3)
expect(result.address_line1_col).toBe(4)
expect(result.postal_code_col).toBe(5)
expect(result.city_col).toBe(6)
expect(result.confidence).toBeGreaterThanOrEqual(0.8)
})
it('detects English headers', () => {
const headers = ['Customer Name', 'Organization Number', 'Email', 'Phone']
const result = detectCustomerColumns(headers)
expect(result.name_col).toBe(0)
expect(result.org_number_col).toBe(1)
expect(result.email_col).toBe(2)
expect(result.phone_col).toBe(3)
})
it('handles missing optional columns', () => {
const headers = ['Kundnamn']
const result = detectCustomerColumns(headers)
expect(result.name_col).toBe(0)
expect(result.email_col).toBeNull()
expect(result.org_number_col).toBeNull()
})
it('does not match the same column twice', () => {
const headers = ['Namn', 'Adress', 'C/O']
const result = detectCustomerColumns(headers)
expect(result.address_line1_col).toBe(1)
expect(result.address_line2_col).toBe(2)
})
it('returns low confidence when name not matched', () => {
const headers = ['ColA', 'ColB']
const result = detectCustomerColumns(headers)
expect(result.confidence).toBe(0)
})
})
@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest'
import * as XLSX from 'xlsx'
import { parseCustomersFile } from '../parser'
function buildXlsx(rows: (string | number)[][]): ArrayBuffer {
const ws = XLSX.utils.aoa_to_sheet(rows)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Kunder')
// Returning a Node Buffer; ArrayBuffer view is interchangeable with XLSX.read
const out = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer
return out
}
describe('parseCustomersFile', () => {
it('parses a basic Swedish customer register', () => {
const buffer = buildXlsx([
['Namn', 'Orgnr', 'E-post', 'Telefon', 'Adress', 'Postnr', 'Ort'],
['Acme AB', '5560217780', 'kontakt@acme.se', '0701234567', 'Storgatan 1', '11122', 'Stockholm'],
['Beta AB', '5562345678', 'info@beta.se', '0709876543', 'Vasagatan 2', '11329', 'Stockholm'],
])
const result = parseCustomersFile(buffer, 'kunder.xlsx')
expect(result.total_rows).toBe(2)
expect(result.rows[0].name).toBe('Acme AB')
expect(result.rows[0].org_number).toBe('5560217780')
expect(result.rows[0].email).toBe('kontakt@acme.se')
expect(result.rows[0].postal_code).toBe('11122')
expect(result.rows[0].city).toBe('Stockholm')
expect(result.rows[0].is_valid).toBe(true)
})
it('auto-classifies customer_type by org_number length', () => {
const buffer = buildXlsx([
['Namn', 'Orgnr'],
['Acme AB', '5560217780'], // 10-digit company
['Sven Svensson', '198001011234'], // 12-digit personnummer
])
const result = parseCustomersFile(buffer, 'mixed.xlsx')
expect(result.rows[0].customer_type).toBe('swedish_business')
expect(result.rows[1].customer_type).toBe('individual')
})
it('classifies eu_business from VAT number prefix', () => {
const buffer = buildXlsx([
['Namn', 'VAT'],
['Müller GmbH', 'DE123456789'],
])
const result = parseCustomersFile(buffer, 'eu.xlsx')
expect(result.rows[0].customer_type).toBe('eu_business')
expect(result.rows[0].vat_number).toBe('DE123456789')
})
it('skips rows with empty name', () => {
const buffer = buildXlsx([
['Namn', 'E-post'],
['Acme AB', 'a@a.se'],
['', 'b@b.se'],
['Beta AB', 'c@c.se'],
])
const result = parseCustomersFile(buffer, 'sparse.xlsx')
expect(result.total_rows).toBe(2)
expect(result.rows.map((r) => r.name)).toEqual(['Acme AB', 'Beta AB'])
})
it('flags invalid email format', () => {
const buffer = buildXlsx([
['Namn', 'E-post'],
['Acme AB', 'not-an-email'],
])
const result = parseCustomersFile(buffer, 'bad-email.xlsx')
expect(result.rows[0].is_valid).toBe(false)
expect(result.rows[0].validation_errors).toContain('Ogiltig e-postadress')
})
it('parses payment terms with day suffix', () => {
const buffer = buildXlsx([
['Namn', 'Betalningsvillkor'],
['Acme AB', '45 dagar'],
['Beta AB', ''],
])
const result = parseCustomersFile(buffer, 'terms.xlsx')
expect(result.rows[0].default_payment_terms).toBe(45)
expect(result.rows[1].default_payment_terms).toBe(30) // default fallback
})
it('returns warning when zero rows match', () => {
const buffer = buildXlsx([
['Namn', 'Orgnr'],
])
const result = parseCustomersFile(buffer, 'empty.xlsx')
expect(result.total_rows).toBe(0)
expect(result.warnings.length).toBeGreaterThan(0)
})
it('honors explicit customer_type column', () => {
const buffer = buildXlsx([
['Namn', 'Kundtyp'],
['Acme AB', 'aktiebolag'],
['Sven', 'privatperson'],
])
const result = parseCustomersFile(buffer, 'types.xlsx')
expect(result.rows[0].customer_type).toBe('swedish_business')
expect(result.rows[1].customer_type).toBe('individual')
})
it('preserves row_index pointing to spreadsheet row', () => {
const buffer = buildXlsx([
['Namn'],
['Acme AB'],
['Beta AB'],
])
const result = parseCustomersFile(buffer, 'rows.xlsx')
expect(result.rows[0].row_index).toBe(2) // header is row 1
expect(result.rows[1].row_index).toBe(3)
})
it('preserves Swedish characters when reading a UTF-8 CSV', () => {
const csv = new TextEncoder().encode(
'Namn,Ort\nAcme AB,GÖTEBORG\nBeta AB,HISINGS KÄRRA\n',
).buffer
const result = parseCustomersFile(csv, 'kunder.csv')
expect(result.rows[0].city).toBe('GÖTEBORG')
expect(result.rows[1].city).toBe('HISINGS KÄRRA')
})
})
+106
View File
@@ -0,0 +1,106 @@
import { findColumn } from '../shared/column-utils'
import type { DetectedCustomerColumns } from './types'
const NAME_KEYWORDS = [
'kundnamn', 'kund namn', 'namn', 'name', 'kund', 'customer', 'customer name',
'företag', 'foretag', 'company', 'företagsnamn', 'foretagsnamn',
]
const ORG_NUMBER_KEYWORDS = [
'orgnr', 'org nr', 'organisationsnummer', 'organisationsnr', 'org',
'personnr', 'personnummer', 'org number', 'organization number',
]
const CUSTOMER_TYPE_KEYWORDS = [
'kundtyp', 'kund typ', 'typ', 'type', 'customer type', 'customer_type',
]
const EMAIL_KEYWORDS = [
'epost', 'e post', 'email', 'mail', 'e mail', 'e-post',
]
const PHONE_KEYWORDS = [
'telefon', 'tel', 'phone', 'mobil', 'mobile', 'telefonnummer',
]
const ADDRESS_LINE1_KEYWORDS = [
'adress', 'address', 'gatuadress', 'street', 'gata',
'address line 1', 'address1', 'adressrad 1',
]
const ADDRESS_LINE2_KEYWORDS = [
'address line 2', 'address2', 'adressrad 2', 'c o', 'co',
]
const POSTAL_CODE_KEYWORDS = [
'postnr', 'postnummer', 'postal code', 'postal_code', 'zip', 'zip code',
]
const CITY_KEYWORDS = ['ort', 'stad', 'city', 'postort']
const COUNTRY_KEYWORDS = ['land', 'country']
const VAT_NUMBER_KEYWORDS = [
'vat', 'vatnr', 'vat nr', 'vat number', 'momsnummer', 'momsregistreringsnummer',
'momsregnr', 'moms nr',
]
const PAYMENT_TERMS_KEYWORDS = [
'betalningsvillkor', 'betalvillkor', 'payment terms', 'kredittid', 'kreditdagar',
'dagar', 'förfallodagar', 'forfallodagar',
]
const NOTES_KEYWORDS = [
'anteckning', 'anteckningar', 'notes', 'kommentar', 'kommentarer', 'comment',
'note', 'beskrivning',
]
/**
* Detect customer-register columns from headers.
* Header-only matching: register imports always have headers, and the column
* structure varies too much to do data-driven fallbacks reliably.
*/
export function detectCustomerColumns(headers: string[]): DetectedCustomerColumns {
const taken = new Set<number>()
const name_col = findColumn(headers, NAME_KEYWORDS, taken) ?? -1
const org_number_col = findColumn(headers, ORG_NUMBER_KEYWORDS, taken)
const customer_type_col = findColumn(headers, CUSTOMER_TYPE_KEYWORDS, taken)
const email_col = findColumn(headers, EMAIL_KEYWORDS, taken)
const phone_col = findColumn(headers, PHONE_KEYWORDS, taken)
const address_line1_col = findColumn(headers, ADDRESS_LINE1_KEYWORDS, taken)
const address_line2_col = findColumn(headers, ADDRESS_LINE2_KEYWORDS, taken)
const postal_code_col = findColumn(headers, POSTAL_CODE_KEYWORDS, taken)
const city_col = findColumn(headers, CITY_KEYWORDS, taken)
const country_col = findColumn(headers, COUNTRY_KEYWORDS, taken)
const vat_number_col = findColumn(headers, VAT_NUMBER_KEYWORDS, taken)
const payment_terms_col = findColumn(headers, PAYMENT_TERMS_KEYWORDS, taken)
const notes_col = findColumn(headers, NOTES_KEYWORDS, taken)
// Confidence: name is required; bonus from how many other columns matched.
let confidence = 0
if (name_col >= 0) {
const matched = [
org_number_col, email_col, phone_col, address_line1_col,
postal_code_col, city_col, vat_number_col, payment_terms_col,
].filter((c) => c !== null).length
confidence = 0.55 + Math.min(matched, 6) * 0.075
}
return {
name_col: name_col >= 0 ? name_col : 0,
org_number_col,
customer_type_col,
email_col,
phone_col,
address_line1_col,
address_line2_col,
postal_code_col,
city_col,
country_col,
vat_number_col,
payment_terms_col,
notes_col,
confidence: Math.min(Math.round(confidence * 100) / 100, 1),
}
}
+199
View File
@@ -0,0 +1,199 @@
import type { CustomerType } from '@/types'
import { detectCustomerColumns } from './column-detector'
import { cellOrNull, parsePaymentTerms } from '../shared/column-utils'
import { classifyCustomer } from '../shared/classify'
import { readBestSheet } from '../shared/workbook-reader'
import type {
DetectedCustomerColumns,
ParsedCustomerRow,
} from './types'
const VALID_CUSTOMER_TYPES: CustomerType[] = [
'individual',
'swedish_business',
'eu_business',
'non_eu_business',
]
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function normalizeCustomerType(value: string | null): CustomerType | null {
if (!value) return null
const lower = value.toLowerCase().trim()
if (lower === 'individual' || lower === 'privat' || lower === 'privatperson' || lower === 'person') {
return 'individual'
}
if (lower === 'swedish_business' || lower === 'swedish' || lower === 'företag' || lower === 'foretag' || lower === 'business' || lower === 'ab' || lower === 'aktiebolag') {
return 'swedish_business'
}
if (lower === 'eu_business' || lower === 'eu') {
return 'eu_business'
}
if (lower === 'non_eu_business' || lower === 'non-eu' || lower === 'utomeu' || lower === 'utländsk' || lower === 'utlandsk') {
return 'non_eu_business'
}
return VALID_CUSTOMER_TYPES.includes(lower as CustomerType)
? (lower as CustomerType)
: null
}
function normalizeCountry(value: string | null): string {
if (!value) return 'Sweden'
const trimmed = value.trim()
const lower = trimmed.toLowerCase()
if (lower === 'se' || lower === 'sverige' || lower === 'sweden') return 'Sweden'
return trimmed
}
/**
* Parse a customer-register file (Excel or CSV) and return structured rows.
*
* @param buffer - Raw file buffer
* @param filename - Original filename
* @param columnOverrides - Optional manual column mapping
*/
export function parseCustomersFile(
buffer: ArrayBuffer,
filename: string,
columnOverrides?: DetectedCustomerColumns,
): {
filename: string
sheet_name: string
total_rows: number
detected_columns: DetectedCustomerColumns
headers: string[]
preview_rows: string[][]
rows: ParsedCustomerRow[]
warnings: string[]
} {
const { sheetName, rawData } = readBestSheet(buffer, filename)
if (rawData.length < 2) {
const fallbackColumns: DetectedCustomerColumns = columnOverrides ?? {
name_col: 0,
org_number_col: null,
customer_type_col: null,
email_col: null,
phone_col: null,
address_line1_col: null,
address_line2_col: null,
postal_code_col: null,
city_col: null,
country_col: null,
vat_number_col: null,
payment_terms_col: null,
notes_col: null,
confidence: 0,
}
return {
filename,
sheet_name: sheetName,
total_rows: 0,
detected_columns: fallbackColumns,
headers: rawData[0]?.map((h) => String(h)) || [],
preview_rows: [],
rows: [],
warnings: ['Filen innehåller för få rader.'],
}
}
const headers = rawData[0].map((h) => String(h))
const dataRows = rawData.slice(1)
const columns = columnOverrides || detectCustomerColumns(headers)
const rows: ParsedCustomerRow[] = []
const warnings: string[] = []
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i]
const name = cellOrNull(row[columns.name_col])
if (!name) continue // skip empty rows silently
const orgNumber = columns.org_number_col !== null
? cellOrNull(row[columns.org_number_col])
: null
const email = columns.email_col !== null
? cellOrNull(row[columns.email_col])
: null
const phone = columns.phone_col !== null
? cellOrNull(row[columns.phone_col])
: null
const addressLine1 = columns.address_line1_col !== null
? cellOrNull(row[columns.address_line1_col])
: null
const addressLine2 = columns.address_line2_col !== null
? cellOrNull(row[columns.address_line2_col])
: null
const postalCode = columns.postal_code_col !== null
? cellOrNull(row[columns.postal_code_col])
: null
const city = columns.city_col !== null
? cellOrNull(row[columns.city_col])
: null
const countryRaw = columns.country_col !== null
? cellOrNull(row[columns.country_col])
: null
const country = normalizeCountry(countryRaw)
const vatNumber = columns.vat_number_col !== null
? cellOrNull(row[columns.vat_number_col])
: null
const paymentTermsRaw = columns.payment_terms_col !== null
? row[columns.payment_terms_col]
: null
const notes = columns.notes_col !== null
? cellOrNull(row[columns.notes_col])
: null
const explicitType = columns.customer_type_col !== null
? normalizeCustomerType(cellOrNull(row[columns.customer_type_col]))
: null
const customerType: CustomerType =
explicitType ?? classifyCustomer({
org_number: orgNumber,
vat_number: vatNumber,
country: countryRaw,
})
const validationErrors: string[] = []
if (email && !EMAIL_RE.test(email)) {
validationErrors.push('Ogiltig e-postadress')
}
if (orgNumber && !/^[\d\s\-]{6,20}$/.test(orgNumber)) {
validationErrors.push('Ogiltigt org-/personnummer')
}
rows.push({
row_index: i + 2, // 1-based + header
name,
customer_type: customerType,
org_number: orgNumber,
email,
phone,
address_line1: addressLine1,
address_line2: addressLine2,
postal_code: postalCode,
city,
country,
vat_number: vatNumber,
default_payment_terms: parsePaymentTerms(paymentTermsRaw, 30),
notes,
is_valid: validationErrors.length === 0,
validation_errors: validationErrors,
})
}
if (rows.length === 0) {
warnings.push('Inga giltiga kundrader hittades. Kontrollera att namnkolumnen är korrekt mappad.')
}
return {
filename,
sheet_name: sheetName,
total_rows: rows.length,
detected_columns: columns,
headers,
preview_rows: dataRows.slice(0, 5),
rows,
warnings,
}
}
+78
View File
@@ -0,0 +1,78 @@
import type { CustomerType } from '@/types'
/** Result of auto-detecting columns in a customer register file. */
export interface DetectedCustomerColumns {
name_col: number
org_number_col: number | null
customer_type_col: number | null
email_col: number | null
phone_col: number | null
address_line1_col: number | null
address_line2_col: number | null
postal_code_col: number | null
city_col: number | null
country_col: number | null
vat_number_col: number | null
payment_terms_col: number | null
notes_col: number | null
/** 0-1 confidence score for the detection */
confidence: number
}
/** A single parsed row from the customer register file. */
export interface ParsedCustomerRow {
row_index: number
name: string
customer_type: CustomerType
org_number: string | null
email: string | null
phone: string | null
address_line1: string | null
address_line2: string | null
postal_code: string | null
city: string | null
country: string
vat_number: string | null
default_payment_terms: number
notes: string | null
is_valid: boolean
validation_errors: string[]
}
/** Customer-row + dedup annotation produced by the API route. */
export interface AnnotatedCustomerRow extends ParsedCustomerRow {
duplicate_match: {
customer_id: string
matched_by: 'org_number' | 'email'
existing_name: string
} | null
}
/** Full result from parsing a customer register file. */
export interface CustomerImportParseResult {
filename: string
sheet_name: string
total_rows: number
detected_columns: DetectedCustomerColumns
headers: string[]
preview_rows: string[][]
rows: AnnotatedCustomerRow[]
duplicate_count: number
warnings: string[]
}
/** Input for executing the customer import. */
export interface CustomerImportExecuteInput {
rows: ParsedCustomerRow[]
update_duplicates: boolean
}
/** Result of executing the customer import. */
export interface CustomerImportExecuteResult {
success: boolean
created: number
updated: number
skipped: number
failed: number
errors: { row_index: number; name: string; reason: string }[]
}
+2 -1
View File
@@ -1,6 +1,7 @@
import * as XLSX from 'xlsx'
import { detectColumns } from './column-detector'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { readWorkbookFromBuffer } from '../shared/workbook-reader'
import type {
DetectedColumns,
ParsedOpeningBalanceRow,
@@ -43,7 +44,7 @@ export function parseOpeningBalanceFile(
filename: string,
columnOverrides?: DetectedColumns,
): OpeningBalanceParseResult {
const workbook = XLSX.read(buffer, { type: 'array' })
const workbook = readWorkbookFromBuffer(buffer, filename)
// Pick the sheet with the most rows (heuristic for multi-sheet workbooks)
let bestSheet = workbook.SheetNames[0]
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest'
import { classifyCustomer, classifySupplier } from '../classify'
describe('classifyCustomer', () => {
it('classifies 12-digit personnummer as individual', () => {
expect(classifyCustomer({
org_number: '198001011234',
vat_number: null,
})).toBe('individual')
})
it('classifies 10-digit Swedish org as swedish_business', () => {
expect(classifyCustomer({
org_number: '5560217780',
vat_number: null,
})).toBe('swedish_business')
})
it('classifies non-SE EU VAT prefix as eu_business', () => {
expect(classifyCustomer({
org_number: null,
vat_number: 'DE123456789',
})).toBe('eu_business')
})
it('classifies non-EU VAT prefix as non_eu_business', () => {
expect(classifyCustomer({
org_number: null,
vat_number: 'NO12345678',
})).toBe('non_eu_business')
})
it('keeps SE VAT prefix as swedish_business', () => {
expect(classifyCustomer({
org_number: '5560217780',
vat_number: 'SE556021778001',
})).toBe('swedish_business')
})
it('classifies 10-digit personnummer-month-pattern as individual', () => {
// Third digit is 0 (month 01), classic personnummer pattern
expect(classifyCustomer({
org_number: '8001011234',
vat_number: null,
})).toBe('individual')
})
it('falls back to swedish_business when no signals', () => {
expect(classifyCustomer({
org_number: null,
vat_number: null,
})).toBe('swedish_business')
})
it('uses country code when VAT missing', () => {
expect(classifyCustomer({
org_number: null,
vat_number: null,
country: 'DE',
})).toBe('eu_business')
})
it('detects Norway as non-EU', () => {
expect(classifyCustomer({
org_number: null,
vat_number: null,
country: 'Norge',
})).toBe('non_eu_business')
})
})
describe('classifySupplier', () => {
it('never returns individual', () => {
expect(classifySupplier({
org_number: '198001011234',
vat_number: null,
})).toBe('swedish_business')
})
it('classifies non-SE EU VAT prefix as eu_business', () => {
expect(classifySupplier({
org_number: null,
vat_number: 'FR12345678901',
})).toBe('eu_business')
})
it('classifies post-Brexit GB VAT as non_eu_business', () => {
expect(classifySupplier({
org_number: null,
vat_number: 'GB123456789',
})).toBe('non_eu_business')
})
it('classifies XI (Northern Ireland) VAT as eu_business', () => {
expect(classifySupplier({
org_number: null,
vat_number: 'XI123456789',
})).toBe('eu_business')
})
it('falls back to swedish_business by default', () => {
expect(classifySupplier({
org_number: null,
vat_number: null,
})).toBe('swedish_business')
})
})
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest'
import {
decodeFileContent,
decodeStringContent,
hasEncodingIssues,
} from '../encoding'
describe('decodeStringContent', () => {
it('recovers UTF-8-as-Latin-1 mojibake for lowercase Swedish chars', () => {
expect(decodeStringContent('Malmö')).toBe('Malmö')
expect(decodeStringContent('Ã¥re')).toBe('Åre'.toLowerCase())
expect(decodeStringContent('Linköping')).toBe('Linköping')
})
it('recovers UTF-8-as-Latin-1 mojibake for uppercase Swedish chars', () => {
// The middle char is U+0096 (control), invisible in most renderings → "GÃTEBORG"
expect(decodeStringContent('GÃ\u0096TEBORG')).toBe('GÖTEBORG')
expect(decodeStringContent('HISINGS KÃ\u0084RRA')).toBe('HISINGS KÄRRA')
expect(decodeStringContent('Ã\u0085NGE')).toBe('ÅNGE')
})
it('is a no-op on already-correct Swedish strings', () => {
expect(decodeStringContent('GÖTEBORG')).toBe('GÖTEBORG')
expect(decodeStringContent('Malmö')).toBe('Malmö')
expect(decodeStringContent('STOCKHOLM')).toBe('STOCKHOLM')
expect(decodeStringContent('')).toBe('')
})
it('is idempotent (running twice equals running once)', () => {
const once = decodeStringContent('Malmö')
const twice = decodeStringContent(once)
expect(twice).toBe(once)
expect(twice).toBe('Malmö')
})
it('preserves non-Swedish strings unchanged', () => {
expect(decodeStringContent('Café')).toBe('Café')
expect(decodeStringContent('München')).toBe('München')
expect(decodeStringContent('123 Main St')).toBe('123 Main St')
})
})
describe('hasEncodingIssues', () => {
it('detects U+FFFD replacement characters', () => {
expect(hasEncodingIssues('Foo\uFFFDbar')).toBe(true)
})
it('detects all six Swedish mojibake patterns', () => {
expect(hasEncodingIssues('Malmö')).toBe(true) // ö
expect(hasEncodingIssues('Ã¥re')).toBe(true) // å
expect(hasEncodingIssues('älg')).toBe(true) // ä
expect(hasEncodingIssues('GÃ\u0096TEBORG')).toBe(true) // Ö
expect(hasEncodingIssues('Ã\u0085NGE')).toBe(true) // Å
expect(hasEncodingIssues('Ã\u0084RRA')).toBe(true) // Ä
})
it('returns false for clean strings', () => {
expect(hasEncodingIssues('Stockholm')).toBe(false)
expect(hasEncodingIssues('Malmö')).toBe(false)
expect(hasEncodingIssues('Café')).toBe(false)
})
})
describe('decodeFileContent', () => {
function buf(bytes: number[]): ArrayBuffer {
return new Uint8Array(bytes).buffer
}
it('decodes UTF-8 bytes correctly', () => {
const utf8 = new TextEncoder().encode('GÖTEBORG').buffer
expect(decodeFileContent(utf8)).toBe('GÖTEBORG')
})
it('falls back to Windows-1252 when UTF-8 decode is invalid', () => {
// 0xD6 = Ö in Windows-1252; lone 0xD6 is not valid UTF-8 start byte
const cp1252 = buf([0x47, 0xd6, 0x54, 0x45, 0x42, 0x4f, 0x52, 0x47])
expect(decodeFileContent(cp1252)).toBe('GÖTEBORG')
})
})
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest'
import * as XLSX from 'xlsx'
import { readWorkbookFromBuffer } from '../workbook-reader'
function bufFromBytes(bytes: number[]): ArrayBuffer {
return new Uint8Array(bytes).buffer
}
function rowsOf(workbook: XLSX.WorkBook): string[][] {
const sheet = workbook.Sheets[workbook.SheetNames[0]]
return XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: false }) as string[][]
}
describe('readWorkbookFromBuffer', () => {
it('decodes UTF-8 CSV with Swedish characters correctly', () => {
const csv = new TextEncoder().encode('Namn,Ort\nAcme,GÖTEBORG\nBeta,KÄRRA\n').buffer
const wb = readWorkbookFromBuffer(csv, 'lev.csv')
expect(rowsOf(wb)).toEqual([
['Namn', 'Ort'],
['Acme', 'GÖTEBORG'],
['Beta', 'KÄRRA'],
])
})
it('decodes UTF-8 CSV with BOM', () => {
const bom = [0xef, 0xbb, 0xbf]
const body = Array.from(new TextEncoder().encode('Namn,Ort\nAcme,GÖTEBORG\n'))
const wb = readWorkbookFromBuffer(bufFromBytes([...bom, ...body]), 'lev.csv')
expect(rowsOf(wb)).toEqual([
['Namn', 'Ort'],
['Acme', 'GÖTEBORG'],
])
})
it('decodes Windows-1252 CSV with Swedish characters', () => {
// "Namn,Ort\nAcme,GÖTEBORG\nBeta,KÄRRA\n" in Windows-1252:
// Ö = 0xD6, Ä = 0xC4
const bytes = [
0x4e, 0x61, 0x6d, 0x6e, 0x2c, 0x4f, 0x72, 0x74, 0x0a,
0x41, 0x63, 0x6d, 0x65, 0x2c, 0x47, 0xd6, 0x54, 0x45, 0x42, 0x4f, 0x52, 0x47, 0x0a,
0x42, 0x65, 0x74, 0x61, 0x2c, 0x4b, 0xc4, 0x52, 0x52, 0x41, 0x0a,
]
const wb = readWorkbookFromBuffer(bufFromBytes(bytes), 'lev.csv')
expect(rowsOf(wb)).toEqual([
['Namn', 'Ort'],
['Acme', 'GÖTEBORG'],
['Beta', 'KÄRRA'],
])
})
it('reads xlsx files via the binary path', () => {
const ws = XLSX.utils.aoa_to_sheet([
['Namn', 'Ort'],
['Acme', 'GÖTEBORG'],
])
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')
const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer
const result = readWorkbookFromBuffer(buffer, 'data.xlsx')
expect(rowsOf(result)).toEqual([
['Namn', 'Ort'],
['Acme', 'GÖTEBORG'],
])
})
it('treats non-csv extensions as binary spreadsheets', () => {
// .xls and .ods both go through the binary path; xlsx handles encoding internally
const ws = XLSX.utils.aoa_to_sheet([['A'], ['Ö']])
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')
const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer
const result = readWorkbookFromBuffer(buffer, 'data.xls')
expect(rowsOf(result)).toEqual([['A'], ['Ö']])
})
})
+106
View File
@@ -0,0 +1,106 @@
import type { CustomerType, SupplierType } from '@/types'
/**
* EU VAT number prefixes (excluding SE).
* Source: https://taxation-ec.europa.eu/online-services/check-vat-number-vies_en
*/
export const EU_VAT_PREFIXES = new Set([
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES',
'FI', 'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV',
'MT', 'NL', 'PL', 'PT', 'RO', 'SI', 'SK', 'XI', // XI = Northern Ireland
])
/**
* Strip whitespace, dashes, and dots from an org/personnummer for length checks.
*/
function digits(value: string | null): string {
if (!value) return ''
return value.replace(/\D/g, '')
}
/**
* Check the third digit of a Swedish org/personnummer.
*
* Personnummer: month digit (00-12) — third digit ≤ 1.
* Företag: third digit ≥ 2 (per Skatteverket's allocation rules).
*/
function looksLikePersonnummer(orgNumber: string | null): boolean {
const d = digits(orgNumber)
// 12 digits = full personnummer (YYYYMMDDXXXX)
if (d.length === 12) return true
// 10 digits — disambiguate by month (positions 3-4 are month, 01-12)
if (d.length === 10) {
const month = parseInt(d.substring(2, 4), 10)
if (month >= 1 && month <= 12 && d[2] <= '1') return true
}
return false
}
function vatPrefix(vatNumber: string | null): string | null {
if (!vatNumber) return null
const cleaned = vatNumber.trim().toUpperCase()
const match = cleaned.match(/^([A-Z]{2})/)
return match ? match[1] : null
}
/**
* Auto-classify a customer based on org_number + vat_number heuristics.
*
* Precedence:
* 1. Non-SE EU VAT prefix → 'eu_business'
* 2. Non-EU letter prefix → 'non_eu_business'
* 3. Personnummer-shaped org → 'individual'
* 4. Default → 'swedish_business'
*/
export function classifyCustomer(args: {
org_number: string | null
vat_number: string | null
country?: string | null
}): CustomerType {
const prefix = vatPrefix(args.vat_number)
if (prefix && prefix !== 'SE') {
if (EU_VAT_PREFIXES.has(prefix)) return 'eu_business'
return 'non_eu_business'
}
const country = args.country?.trim().toUpperCase()
if (country && country !== 'SE' && country !== 'SVERIGE' && country !== 'SWEDEN') {
// Country-based fallback when VAT is missing.
if (country.length === 2 && EU_VAT_PREFIXES.has(country)) return 'eu_business'
if (country.length >= 3) {
// Common Swedish names for non-EU jurisdictions; heuristic is best-effort
if (/norge|norway|usa|kanada|canada|storbritannien|uk|united kingdom/i.test(country)) {
return 'non_eu_business'
}
}
}
if (looksLikePersonnummer(args.org_number)) return 'individual'
return 'swedish_business'
}
/**
* Auto-classify a supplier. Suppliers cannot be 'individual' — Swedish business
* with personnummer is still 'swedish_business' (a sole trader supplier).
*/
export function classifySupplier(args: {
org_number: string | null
vat_number: string | null
country?: string | null
}): SupplierType {
const prefix = vatPrefix(args.vat_number)
if (prefix && prefix !== 'SE') {
if (EU_VAT_PREFIXES.has(prefix)) return 'eu_business'
return 'non_eu_business'
}
const country = args.country?.trim().toUpperCase()
if (country && country !== 'SE' && country !== 'SVERIGE' && country !== 'SWEDEN') {
if (country.length === 2 && EU_VAT_PREFIXES.has(country)) return 'eu_business'
if (/norge|norway|usa|kanada|canada|storbritannien|uk|united kingdom/i.test(country)) {
return 'non_eu_business'
}
}
return 'swedish_business'
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Shared column-detection helpers for register imports
* (customers, suppliers, future: articles).
*/
export function normalize(header: string): string {
return header.toLowerCase().trim().replace(/[_\-./]/g, ' ')
}
export function matchesKeywords(header: string, keywords: string[]): boolean {
const normalized = normalize(header)
return keywords.some((kw) => normalized === kw || normalized.includes(kw))
}
/**
* Find the first column index whose header matches one of `keywords`,
* skipping any indices already taken by other columns.
*/
export function findColumn(
headers: string[],
keywords: string[],
taken: Set<number>,
): number | null {
for (let i = 0; i < headers.length; i++) {
if (taken.has(i)) continue
if (matchesKeywords(headers[i], keywords)) {
taken.add(i)
return i
}
}
return null
}
/** Trim a string-or-blank cell, returning null when empty. */
export function cellOrNull(value: unknown): string | null {
if (value === null || value === undefined) return null
const str = String(value).trim()
return str === '' ? null : str
}
/** Parse an integer payment term ("30 dagar" → 30) with a default fallback. */
export function parsePaymentTerms(value: unknown, fallback: number): number {
const str = cellOrNull(value)
if (!str) return fallback
const match = str.match(/-?\d+/)
if (!match) return fallback
const n = parseInt(match[0], 10)
if (isNaN(n) || n < 0 || n > 365) return fallback
return n
}
/**
* Normalize an org/personal number to its dedup key (digits only).
* Returns null for empty input or strings that contain no digits.
*/
export function normalizeOrgNumber(value: string | null): string | null {
if (!value) return null
return value.replace(/\D/g, '') || null
}
/**
* Normalize an email to its dedup key (trimmed + lowercased).
* Returns null for empty/whitespace-only input.
*/
export function normalizeEmail(value: string | null): string | null {
if (!value) return null
return value.trim().toLowerCase() || null
}
@@ -1,7 +1,8 @@
/**
* Encoding detection and conversion for Swedish bank files.
* Encoding detection and conversion for Swedish import files.
*
* Swedish bank exports use either UTF-8 or Windows-1252 (ISO-8859-1).
* Used by bank file, supplier, customer, and opening-balance parsers.
* Swedish data exports use either UTF-8 or Windows-1252 (ISO-8859-1).
* We detect encoding by checking for valid Swedish characters.
*/
@@ -12,37 +13,39 @@
* (U+FFFD) or garbled Swedish chars, fall back to Windows-1252.
*/
export function decodeFileContent(buffer: ArrayBuffer): string {
// Try UTF-8 first
const utf8Decoder = new TextDecoder('utf-8', { fatal: false })
const utf8Result = utf8Decoder.decode(buffer)
// Check if UTF-8 decode produced valid Swedish text
if (!hasEncodingIssues(utf8Result)) {
return utf8Result
}
// Fall back to Windows-1252 (superset of ISO-8859-1)
const latin1Decoder = new TextDecoder('windows-1252', { fatal: false })
return latin1Decoder.decode(buffer)
}
/**
* Decode a string that may have been incorrectly decoded as UTF-8
* when the source was actually Windows-1252.
* Re-decode a string that suffered the canonical "UTF-8 bytes read as Latin-1"
* mojibake (e.g. "Malmö" → "Malmö", "GÖTEBORG" → "GÖTEBORG").
*
* Mechanism: each char in the input is a codepoint that was originally a UTF-8
* byte misinterpreted as a Latin-1/Windows-1252 character. We pack those chars
* back into a byte sequence and decode the bytes as UTF-8 to recover the
* original text.
*
* No-op when the string is already clean (no garbled patterns).
*/
export function decodeStringContent(content: string): string {
// If the string already contains valid Swedish chars, return as-is
if (!hasEncodingIssues(content)) {
return content
}
// Try re-encoding as Latin-1 and decoding as Windows-1252
try {
const bytes = new Uint8Array(content.length)
for (let i = 0; i < content.length; i++) {
bytes[i] = content.charCodeAt(i) & 0xff
}
const decoder = new TextDecoder('windows-1252', { fatal: false })
const decoder = new TextDecoder('utf-8', { fatal: false })
return decoder.decode(bytes)
} catch {
return content
@@ -52,8 +55,7 @@ export function decodeStringContent(content: string): string {
/**
* Check if a string has encoding issues (garbled Swedish characters).
*/
function hasEncodingIssues(text: string): boolean {
// U+FFFD = replacement character (means invalid UTF-8 byte sequences)
export function hasEncodingIssues(text: string): boolean {
if (text.includes('\uFFFD')) return true
// Common garbled patterns when Windows-1252 is read as UTF-8:
+58
View File
@@ -0,0 +1,58 @@
import * as XLSX from 'xlsx'
import { decodeFileContent } from './encoding'
/**
* Read a workbook from a raw file buffer, with correct encoding handling
* for CSV files.
*
* For binary spreadsheet formats (.xlsx, .xls, .ods), xlsx handles encoding
* via the embedded codepage and we pass the buffer through as `type: 'array'`.
*
* For CSV files, xlsx with `type: 'array'` decodes bytes as Latin-1, which
* mangles UTF-8 multi-byte sequences (e.g. Ö → Ö). We instead detect the
* source encoding (UTF-8 with optional BOM, or Windows-1252) and decode to
* a string before handing it to xlsx as `type: 'string'`.
*/
export function readWorkbookFromBuffer(buffer: ArrayBuffer, filename: string): XLSX.WorkBook {
const ext = filename.toLowerCase().split('.').pop() ?? ''
if (ext === 'csv') {
const content = decodeFileContent(buffer)
return XLSX.read(content, { type: 'string' })
}
return XLSX.read(buffer, { type: 'array' })
}
/**
* Read the workbook from `buffer` and return raw rows from its largest sheet.
*
* Picks the sheet with the most rows (a heuristic that handles files where
* the header sheet isn't the first one). Returns rows as a 2D string array
* with the header row included; cells default to empty string.
*/
export function readBestSheet(
buffer: ArrayBuffer,
filename: string,
): { sheetName: string; rawData: string[][] } {
const workbook = readWorkbookFromBuffer(buffer, filename)
let bestSheet = workbook.SheetNames[0]
let bestRowCount = 0
for (const name of workbook.SheetNames) {
const sheet = workbook.Sheets[name]
const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1')
const rowCount = range.e.r - range.s.r + 1
if (rowCount > bestRowCount) {
bestRowCount = rowCount
bestSheet = name
}
}
const sheet = workbook.Sheets[bestSheet]
const rawData: string[][] = XLSX.utils.sheet_to_json(sheet, {
header: 1,
defval: '',
raw: false,
})
return { sheetName: bestSheet, rawData }
}
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { detectSupplierColumns } from '../column-detector'
describe('detectSupplierColumns', () => {
it('detects Swedish supplier register headers', () => {
const headers = ['Namn', 'Orgnr', 'Bankgiro', 'Plusgiro', 'IBAN', 'BIC', 'E-post']
const result = detectSupplierColumns(headers)
expect(result.name_col).toBe(0)
expect(result.org_number_col).toBe(1)
expect(result.bankgiro_col).toBe(2)
expect(result.plusgiro_col).toBe(3)
expect(result.iban_col).toBe(4)
expect(result.bic_col).toBe(5)
expect(result.email_col).toBe(6)
expect(result.confidence).toBeGreaterThanOrEqual(0.8)
})
it('handles supplier-specific keyword "Leverantör"', () => {
const headers = ['Leverantör', 'Orgnummer', 'Bankgiro']
const result = detectSupplierColumns(headers)
expect(result.name_col).toBe(0)
expect(result.org_number_col).toBe(1)
expect(result.bankgiro_col).toBe(2)
})
it('does not confuse plusgiro with bankgiro', () => {
const headers = ['Namn', 'Plusgiro', 'Bankgiro']
const result = detectSupplierColumns(headers)
expect(result.plusgiro_col).toBe(1)
expect(result.bankgiro_col).toBe(2)
})
it('returns confidence 0 with no name column', () => {
const headers = ['ColA', 'ColB']
const result = detectSupplierColumns(headers)
expect(result.confidence).toBe(0)
})
})
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest'
import * as XLSX from 'xlsx'
import { parseSuppliersFile } from '../parser'
function buildXlsx(rows: (string | number)[][]): ArrayBuffer {
const ws = XLSX.utils.aoa_to_sheet(rows)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Leverantörer')
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer
}
describe('parseSuppliersFile', () => {
it('parses Swedish supplier register with bankgiro/iban', () => {
const buffer = buildXlsx([
['Namn', 'Orgnr', 'Bankgiro', 'Plusgiro', 'IBAN', 'BIC'],
['Acme AB', '5560217780', '123-4567', '12 34 56-7', 'SE3550000000054910000003', 'ESSESESS'],
])
const result = parseSuppliersFile(buffer, 'lev.xlsx')
expect(result.rows[0].name).toBe('Acme AB')
expect(result.rows[0].bankgiro).toBe('123-4567')
expect(result.rows[0].plusgiro).toBe('123456-7')
expect(result.rows[0].iban).toBe('SE3550000000054910000003')
expect(result.rows[0].bic).toBe('ESSESESS')
expect(result.rows[0].is_valid).toBe(true)
})
it('classifies eu_business by VAT prefix', () => {
const buffer = buildXlsx([
['Namn', 'VAT'],
['Müller GmbH', 'DE123456789'],
])
const result = parseSuppliersFile(buffer, 'eu.xlsx')
expect(result.rows[0].supplier_type).toBe('eu_business')
})
it('flags invalid IBAN format', () => {
const buffer = buildXlsx([
['Namn', 'IBAN'],
['Acme AB', 'NOT-AN-IBAN'],
])
const result = parseSuppliersFile(buffer, 'bad-iban.xlsx')
expect(result.rows[0].is_valid).toBe(false)
expect(result.rows[0].validation_errors).toContain('Ogiltigt IBAN')
})
it('defaults currency to SEK when missing or invalid', () => {
const buffer = buildXlsx([
['Namn', 'Valuta'],
['Acme AB', ''],
['Beta AB', 'XYZ'],
['Gamma AB', 'EUR'],
])
const result = parseSuppliersFile(buffer, 'curr.xlsx')
expect(result.rows[0].default_currency).toBe('SEK')
expect(result.rows[1].default_currency).toBe('SEK')
expect(result.rows[2].default_currency).toBe('EUR')
})
it('skips rows with empty name', () => {
const buffer = buildXlsx([
['Namn'],
['Acme AB'],
[''],
['Beta AB'],
])
const result = parseSuppliersFile(buffer, 'sparse.xlsx')
expect(result.total_rows).toBe(2)
})
it('cleans bankgiro number formatting', () => {
const buffer = buildXlsx([
['Namn', 'Bankgiro'],
['Acme AB', '5402 9685'],
])
const result = parseSuppliersFile(buffer, 'bg.xlsx')
expect(result.rows[0].bankgiro).toBe('54029685')
})
it('preserves Swedish characters when reading a UTF-8 CSV', () => {
const csv = new TextEncoder().encode(
'Namn,Ort\nDinel AB,GÖTEBORG\nHisings AB,HISINGS KÄRRA\n',
).buffer
const result = parseSuppliersFile(csv, 'lev.csv')
expect(result.rows[0].city).toBe('GÖTEBORG')
expect(result.rows[1].city).toBe('HISINGS KÄRRA')
})
it('preserves Swedish characters when reading a Windows-1252 CSV', () => {
// Ö = 0xD6, Ä = 0xC4 in Windows-1252
const bytes = [
0x4e, 0x61, 0x6d, 0x6e, 0x2c, 0x4f, 0x72, 0x74, 0x0a, // "Namn,Ort\n"
0x41, 0x63, 0x6d, 0x65, 0x2c, 0x47, 0xd6, 0x54, 0x45, 0x42, 0x4f, 0x52, 0x47, 0x0a, // "Acme,GÖTEBORG\n"
0x42, 0x65, 0x74, 0x61, 0x2c, 0x4b, 0xc4, 0x52, 0x52, 0x41, 0x0a, // "Beta,KÄRRA\n"
]
const result = parseSuppliersFile(new Uint8Array(bytes).buffer, 'lev.csv')
expect(result.rows[0].city).toBe('GÖTEBORG')
expect(result.rows[1].city).toBe('KÄRRA')
})
})
+130
View File
@@ -0,0 +1,130 @@
import { findColumn } from '../shared/column-utils'
import type { DetectedSupplierColumns } from './types'
const NAME_KEYWORDS = [
'leverantörsnamn', 'leverantorsnamn', 'leverantör', 'leverantor', 'namn', 'name',
'supplier', 'supplier name', 'företag', 'foretag', 'company', 'vendor',
]
const ORG_NUMBER_KEYWORDS = [
'orgnr', 'org nr', 'organisationsnummer', 'organisationsnr', 'org',
'personnr', 'personnummer', 'org number', 'organization number',
]
const SUPPLIER_TYPE_KEYWORDS = [
'leverantörstyp', 'leverantorstyp', 'typ', 'type', 'supplier type',
'supplier_type',
]
const EMAIL_KEYWORDS = ['epost', 'e post', 'email', 'mail', 'e mail', 'e-post']
const PHONE_KEYWORDS = [
'telefon', 'tel', 'phone', 'mobil', 'mobile', 'telefonnummer',
]
const ADDRESS_LINE1_KEYWORDS = [
'adress', 'address', 'gatuadress', 'street', 'gata',
'address line 1', 'address1', 'adressrad 1',
]
const ADDRESS_LINE2_KEYWORDS = [
'address line 2', 'address2', 'adressrad 2', 'c o', 'co',
]
const POSTAL_CODE_KEYWORDS = [
'postnr', 'postnummer', 'postal code', 'postal_code', 'zip', 'zip code',
]
const CITY_KEYWORDS = ['ort', 'stad', 'city', 'postort']
const COUNTRY_KEYWORDS = ['land', 'country']
const VAT_NUMBER_KEYWORDS = [
'vat', 'vatnr', 'vat nr', 'vat number', 'momsnummer', 'momsregistreringsnummer',
'momsregnr', 'moms nr',
]
const BANKGIRO_KEYWORDS = [
'bankgiro', 'bg', 'bgnr', 'bg nr', 'bankgironr', 'bankgironummer',
]
const PLUSGIRO_KEYWORDS = [
'plusgiro', 'pg', 'pgnr', 'pg nr', 'postgiro', 'plusgironummer',
]
const BANK_ACCOUNT_KEYWORDS = [
'bankkonto', 'kontonummer', 'bank account', 'bank_account', 'clearing',
]
const IBAN_KEYWORDS = ['iban', 'iban nr']
const BIC_KEYWORDS = ['bic', 'swift', 'swift code', 'bic code']
const PAYMENT_TERMS_KEYWORDS = [
'betalningsvillkor', 'betalvillkor', 'payment terms', 'kredittid', 'kreditdagar',
'dagar', 'förfallodagar', 'forfallodagar',
]
const CURRENCY_KEYWORDS = ['valuta', 'currency', 'curr']
const NOTES_KEYWORDS = [
'anteckning', 'anteckningar', 'notes', 'kommentar', 'kommentarer', 'comment',
'note', 'beskrivning',
]
export function detectSupplierColumns(headers: string[]): DetectedSupplierColumns {
const taken = new Set<number>()
const name_col = findColumn(headers, NAME_KEYWORDS, taken) ?? -1
const org_number_col = findColumn(headers, ORG_NUMBER_KEYWORDS, taken)
const supplier_type_col = findColumn(headers, SUPPLIER_TYPE_KEYWORDS, taken)
const email_col = findColumn(headers, EMAIL_KEYWORDS, taken)
const phone_col = findColumn(headers, PHONE_KEYWORDS, taken)
const address_line1_col = findColumn(headers, ADDRESS_LINE1_KEYWORDS, taken)
const address_line2_col = findColumn(headers, ADDRESS_LINE2_KEYWORDS, taken)
const postal_code_col = findColumn(headers, POSTAL_CODE_KEYWORDS, taken)
const city_col = findColumn(headers, CITY_KEYWORDS, taken)
const country_col = findColumn(headers, COUNTRY_KEYWORDS, taken)
const vat_number_col = findColumn(headers, VAT_NUMBER_KEYWORDS, taken)
const bankgiro_col = findColumn(headers, BANKGIRO_KEYWORDS, taken)
const plusgiro_col = findColumn(headers, PLUSGIRO_KEYWORDS, taken)
const bank_account_col = findColumn(headers, BANK_ACCOUNT_KEYWORDS, taken)
const iban_col = findColumn(headers, IBAN_KEYWORDS, taken)
const bic_col = findColumn(headers, BIC_KEYWORDS, taken)
const payment_terms_col = findColumn(headers, PAYMENT_TERMS_KEYWORDS, taken)
const default_currency_col = findColumn(headers, CURRENCY_KEYWORDS, taken)
const notes_col = findColumn(headers, NOTES_KEYWORDS, taken)
let confidence = 0
if (name_col >= 0) {
const matched = [
org_number_col, email_col, phone_col, address_line1_col,
postal_code_col, city_col, vat_number_col, bankgiro_col, iban_col,
payment_terms_col,
].filter((c) => c !== null).length
confidence = 0.55 + Math.min(matched, 6) * 0.075
}
return {
name_col: name_col >= 0 ? name_col : 0,
org_number_col,
supplier_type_col,
email_col,
phone_col,
address_line1_col,
address_line2_col,
postal_code_col,
city_col,
country_col,
vat_number_col,
bankgiro_col,
plusgiro_col,
bank_account_col,
iban_col,
bic_col,
payment_terms_col,
default_currency_col,
notes_col,
confidence: Math.min(Math.round(confidence * 100) / 100, 1),
}
}
+206
View File
@@ -0,0 +1,206 @@
import type { SupplierType } from '@/types'
import { detectSupplierColumns } from './column-detector'
import { cellOrNull, parsePaymentTerms } from '../shared/column-utils'
import { classifySupplier } from '../shared/classify'
import { readBestSheet } from '../shared/workbook-reader'
import type {
DetectedSupplierColumns,
ParsedSupplierRow,
} from './types'
const VALID_SUPPLIER_TYPES: SupplierType[] = [
'swedish_business',
'eu_business',
'non_eu_business',
]
const VALID_CURRENCIES = new Set(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'])
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function normalizeSupplierType(value: string | null): SupplierType | null {
if (!value) return null
const lower = value.toLowerCase().trim()
if (lower === 'swedish_business' || lower === 'swedish' || lower === 'svensk' || lower === 'företag' || lower === 'foretag' || lower === 'business' || lower === 'ab' || lower === 'aktiebolag') {
return 'swedish_business'
}
if (lower === 'eu_business' || lower === 'eu') {
return 'eu_business'
}
if (lower === 'non_eu_business' || lower === 'non-eu' || lower === 'utomeu' || lower === 'utländsk' || lower === 'utlandsk') {
return 'non_eu_business'
}
return VALID_SUPPLIER_TYPES.includes(lower as SupplierType)
? (lower as SupplierType)
: null
}
function normalizeCountry(value: string | null): string {
if (!value) return 'SE'
const trimmed = value.trim()
const lower = trimmed.toLowerCase()
if (lower === 'se' || lower === 'sverige' || lower === 'sweden') return 'SE'
return trimmed
}
function normalizeCurrency(value: string | null): string {
if (!value) return 'SEK'
const upper = value.trim().toUpperCase()
return VALID_CURRENCIES.has(upper) ? upper : 'SEK'
}
function cleanGiroNumber(value: string | null): string | null {
if (!value) return null
const cleaned = value.replace(/[\s.]/g, '')
return cleaned === '' ? null : cleaned
}
export function parseSuppliersFile(
buffer: ArrayBuffer,
filename: string,
columnOverrides?: DetectedSupplierColumns,
): {
filename: string
sheet_name: string
total_rows: number
detected_columns: DetectedSupplierColumns
headers: string[]
preview_rows: string[][]
rows: ParsedSupplierRow[]
warnings: string[]
} {
const { sheetName, rawData } = readBestSheet(buffer, filename)
if (rawData.length < 2) {
const fallbackColumns: DetectedSupplierColumns = columnOverrides ?? {
name_col: 0,
org_number_col: null,
supplier_type_col: null,
email_col: null,
phone_col: null,
address_line1_col: null,
address_line2_col: null,
postal_code_col: null,
city_col: null,
country_col: null,
vat_number_col: null,
bankgiro_col: null,
plusgiro_col: null,
bank_account_col: null,
iban_col: null,
bic_col: null,
payment_terms_col: null,
default_currency_col: null,
notes_col: null,
confidence: 0,
}
return {
filename,
sheet_name: sheetName,
total_rows: 0,
detected_columns: fallbackColumns,
headers: rawData[0]?.map((h) => String(h)) || [],
preview_rows: [],
rows: [],
warnings: ['Filen innehåller för få rader.'],
}
}
const headers = rawData[0].map((h) => String(h))
const dataRows = rawData.slice(1)
const columns = columnOverrides || detectSupplierColumns(headers)
const rows: ParsedSupplierRow[] = []
const warnings: string[] = []
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i]
const name = cellOrNull(row[columns.name_col])
if (!name) continue
const get = (col: number | null) =>
col !== null ? cellOrNull(row[col]) : null
const orgNumber = get(columns.org_number_col)
const email = get(columns.email_col)
const phone = get(columns.phone_col)
const addressLine1 = get(columns.address_line1_col)
const addressLine2 = get(columns.address_line2_col)
const postalCode = get(columns.postal_code_col)
const city = get(columns.city_col)
const countryRaw = get(columns.country_col)
const country = normalizeCountry(countryRaw)
const vatNumber = get(columns.vat_number_col)
const bankgiro = cleanGiroNumber(get(columns.bankgiro_col))
const plusgiro = cleanGiroNumber(get(columns.plusgiro_col))
const bankAccount = get(columns.bank_account_col)
const iban = get(columns.iban_col)?.replace(/\s/g, '').toUpperCase() ?? null
const bic = get(columns.bic_col)?.replace(/\s/g, '').toUpperCase() ?? null
const paymentTermsRaw = columns.payment_terms_col !== null
? row[columns.payment_terms_col]
: null
const currencyRaw = get(columns.default_currency_col)
const notes = get(columns.notes_col)
const explicitType = columns.supplier_type_col !== null
? normalizeSupplierType(cellOrNull(row[columns.supplier_type_col]))
: null
const supplierType: SupplierType =
explicitType ?? classifySupplier({
org_number: orgNumber,
vat_number: vatNumber,
country: countryRaw,
})
const validationErrors: string[] = []
if (email && !EMAIL_RE.test(email)) {
validationErrors.push('Ogiltig e-postadress')
}
if (orgNumber && !/^[\d\s\-]{6,20}$/.test(orgNumber)) {
validationErrors.push('Ogiltigt org-/personnummer')
}
if (iban && !/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(iban)) {
validationErrors.push('Ogiltigt IBAN')
}
rows.push({
row_index: i + 2,
name,
supplier_type: supplierType,
org_number: orgNumber,
email,
phone,
address_line1: addressLine1,
address_line2: addressLine2,
postal_code: postalCode,
city,
country,
vat_number: vatNumber,
bankgiro,
plusgiro,
bank_account: bankAccount,
iban,
bic,
default_payment_terms: parsePaymentTerms(paymentTermsRaw, 30),
default_currency: normalizeCurrency(currencyRaw),
notes,
is_valid: validationErrors.length === 0,
validation_errors: validationErrors,
})
}
if (rows.length === 0) {
warnings.push('Inga giltiga leverantörsrader hittades. Kontrollera att namnkolumnen är korrekt mappad.')
}
return {
filename,
sheet_name: sheetName,
total_rows: rows.length,
detected_columns: columns,
headers,
preview_rows: dataRows.slice(0, 5),
rows,
warnings,
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { SupplierType } from '@/types'
/** Result of auto-detecting columns in a supplier register file. */
export interface DetectedSupplierColumns {
name_col: number
org_number_col: number | null
supplier_type_col: number | null
email_col: number | null
phone_col: number | null
address_line1_col: number | null
address_line2_col: number | null
postal_code_col: number | null
city_col: number | null
country_col: number | null
vat_number_col: number | null
bankgiro_col: number | null
plusgiro_col: number | null
bank_account_col: number | null
iban_col: number | null
bic_col: number | null
payment_terms_col: number | null
default_currency_col: number | null
notes_col: number | null
/** 0-1 confidence score for the detection */
confidence: number
}
/** A single parsed row from the supplier register file. */
export interface ParsedSupplierRow {
row_index: number
name: string
supplier_type: SupplierType
org_number: string | null
email: string | null
phone: string | null
address_line1: string | null
address_line2: string | null
postal_code: string | null
city: string | null
country: string
vat_number: string | null
bankgiro: string | null
plusgiro: string | null
bank_account: string | null
iban: string | null
bic: string | null
default_payment_terms: number
default_currency: string
notes: string | null
is_valid: boolean
validation_errors: string[]
}
/** Supplier-row + dedup annotation produced by the API route. */
export interface AnnotatedSupplierRow extends ParsedSupplierRow {
duplicate_match: {
supplier_id: string
matched_by: 'org_number' | 'email'
existing_name: string
} | null
}
/** Full result from parsing a supplier register file. */
export interface SupplierImportParseResult {
filename: string
sheet_name: string
total_rows: number
detected_columns: DetectedSupplierColumns
headers: string[]
preview_rows: string[][]
rows: AnnotatedSupplierRow[]
duplicate_count: number
warnings: string[]
}
/** Input for executing the supplier import. */
export interface SupplierImportExecuteInput {
rows: ParsedSupplierRow[]
update_duplicates: boolean
}
/** Result of executing the supplier import. */
export interface SupplierImportExecuteResult {
success: boolean
created: number
updated: number
skipped: number
failed: number
errors: { row_index: number; name: string; reason: string }[]
}
+13
View File
@@ -0,0 +1,13 @@
export function clearRecaptIdentity(): void {
if (typeof window === 'undefined') return
if (typeof window.recapt !== 'function') return
try {
window.recapt('identify', {
uid: undefined,
email: undefined,
nickname: undefined,
})
} catch {
// best-effort — we're already in a logout flow
}
}
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env npx tsx
/**
* Re-decode mojibake'd Swedish characters in supplier and customer text fields.
*
* Background: prior to the CSV-encoding fix in lib/import/shared/workbook-reader.ts,
* the supplier/customer CSV importer passed raw bytes to xlsx with `type: 'array'`,
* which decodes UTF-8 multi-byte sequences as Latin-1 — turning "GÖTEBORG" into
* "GÖTEBORG" (and similar for å, ä, å, Å, Ä, Ö). This script repairs those rows.
*
* Idempotent: lib/import/shared/encoding.ts:decodeStringContent is a no-op on
* strings that already contain correct Swedish characters, so it is safe to
* re-run.
*
* Usage:
* # Preview every company
* npx tsx scripts/fix-import-mojibake.ts
*
* # Preview a single company
* npx tsx scripts/fix-import-mojibake.ts --company-id <uuid>
*
* # Apply
* npx tsx scripts/fix-import-mojibake.ts --commit
* npx tsx scripts/fix-import-mojibake.ts --company-id <uuid> --commit
*/
import { config } from 'dotenv'
config({ path: '.env.local' })
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { decodeStringContent, hasEncodingIssues } from '../lib/import/shared/encoding'
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`)
return i >= 0 ? process.argv[i + 1] : undefined
}
const COMPANY_ID = arg('company-id') ?? null
const COMMIT = process.argv.includes('--commit')
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !serviceRoleKey) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
process.exit(1)
}
const supabase = createClient(supabaseUrl, serviceRoleKey) as SupabaseClient
console.log('─────────────────────────────────────────────────────────')
console.log('Mojibake repair: suppliers + customers')
console.log('─────────────────────────────────────────────────────────')
console.log('Supabase URL :', supabaseUrl)
console.log('Company :', COMPANY_ID ?? '(all)')
console.log('Mode :', COMMIT ? 'COMMIT (writes)' : 'DRY RUN (no writes)')
console.log('─────────────────────────────────────────────────────────\n')
const SUPPLIER_TEXT_FIELDS = [
'name',
'address_line1',
'address_line2',
'city',
'country',
'category',
'notes',
] as const
const CUSTOMER_TEXT_FIELDS = [
'name',
'address_line1',
'address_line2',
'city',
'country',
'notes',
] as const
const PAGE_SIZE = 500
async function fetchAll(table: 'suppliers' | 'customers', columns: string[]) {
const all: Record<string, unknown>[] = []
let from = 0
for (;;) {
let query = supabase
.from(table)
.select(['id', 'company_id', ...columns].join(','))
.order('id', { ascending: true })
.range(from, from + PAGE_SIZE - 1)
if (COMPANY_ID) {
query = query.eq('company_id', COMPANY_ID)
}
const { data, error } = await query
if (error) throw new Error(`Failed to read ${table}: ${error.message}`)
const rows = ((data ?? []) as unknown) as Record<string, unknown>[]
all.push(...rows)
if (rows.length < PAGE_SIZE) break
from += PAGE_SIZE
}
return all
}
interface RepairResult {
scanned: number
affected: number
fields: number
applied: number
failed: number
}
async function repair(
table: 'suppliers' | 'customers',
textFields: readonly string[],
): Promise<RepairResult> {
console.log(`[${table}]`)
const rows = await fetchAll(table, [...textFields])
console.log(` · Scanned ${rows.length} rows`)
let affected = 0
let fieldsFixed = 0
let applied = 0
let failed = 0
for (const row of rows) {
const updates: Record<string, string | null> = {}
const before: Record<string, string> = {}
for (const field of textFields) {
const value = row[field]
if (typeof value !== 'string' || value === '') continue
if (!hasEncodingIssues(value)) continue
const fixed = decodeStringContent(value)
if (fixed === value) continue
updates[field] = fixed
before[field] = value
}
if (Object.keys(updates).length === 0) continue
affected++
fieldsFixed += Object.keys(updates).length
const id = row.id as string
const companyId = row.company_id as string
console.log(`\n · ${table}.${id} (company ${companyId})`)
for (const [field, fixed] of Object.entries(updates)) {
console.log(` ${field}:`)
console.log(` before: ${JSON.stringify(before[field])}`)
console.log(` after : ${JSON.stringify(fixed)}`)
}
if (!COMMIT) continue
const { error } = await supabase.from(table).update(updates).eq('id', id)
if (error) {
console.error(` FAILED: ${error.message}`)
failed++
} else {
applied++
}
}
return { scanned: rows.length, affected, fields: fieldsFixed, applied, failed }
}
async function main() {
try {
const sup = await repair('suppliers', SUPPLIER_TEXT_FIELDS)
const cus = await repair('customers', CUSTOMER_TEXT_FIELDS)
console.log('\n─────────────────────────────────────────────────────────')
console.log('Summary')
console.log('─────────────────────────────────────────────────────────')
console.log(`suppliers : ${sup.affected}/${sup.scanned} rows affected (${sup.fields} fields)`)
console.log(`customers : ${cus.affected}/${cus.scanned} rows affected (${cus.fields} fields)`)
if (COMMIT) {
console.log(`Applied : ${sup.applied + cus.applied}`)
console.log(`Failed : ${sup.failed + cus.failed}`)
} else {
console.log('\nRe-run with --commit to apply.')
}
} catch (err) {
console.error('\nFATAL:', err instanceof Error ? err.message : err)
process.exit(1)
}
}
main()
+13 -1
View File
@@ -2,9 +2,21 @@ type RecaptFeedbackPayload =
| { message: string; rating?: number }
| { widget: 'show' | 'hide' | 'open' | 'close'; position?: string }
type RecaptIdentifyPayload = {
uid: string | undefined
email?: string
nickname?: string
}
interface RecaptFn {
(action: 'feedback', data: RecaptFeedbackPayload): void
(action: 'identify', data: RecaptIdentifyPayload): void
}
declare global {
interface Window {
recapt?: (action: 'feedback', data: RecaptFeedbackPayload) => void
Recapt?: unknown
recapt?: RecaptFn
}
}