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 },