diff --git a/app/(dashboard)/articles/page.tsx b/app/(dashboard)/articles/page.tsx index 34214667..fd8c7823 100644 --- a/app/(dashboard)/articles/page.tsx +++ b/app/(dashboard)/articles/page.tsx @@ -29,6 +29,7 @@ import { } from '@/lib/hooks/use-submit-with-account-activation' import { EmptyState } from '@/components/ui/empty-state' import { PageHeader } from '@/components/ui/page-header' +import { ReportExportMenu } from '@/components/reports/ReportExportMenu' import { formatCurrency } from '@/lib/utils' import Link from 'next/link' import { useCompany } from '@/contexts/CompanyContext' @@ -237,30 +238,38 @@ function ArticlesPageInner() { - - - - - - {t('add_article')} - - - - +
+ + + + + + + + {t('add_article')} + + + + +
} /> diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx index a6cece36..226bc466 100644 --- a/app/(dashboard)/customers/page.tsx +++ b/app/(dashboard)/customers/page.tsx @@ -24,6 +24,7 @@ import { Plus, Search, Users, Lock, ChevronUp, ChevronDown, ChevronsUpDown } fro import CustomerForm from '@/components/customers/CustomerForm' import { EmptyCustomers, EmptyState } from '@/components/ui/empty-state' import { PageHeader } from '@/components/ui/page-header' +import { ReportExportMenu } from '@/components/reports/ReportExportMenu' import { formatDate } from '@/lib/utils' import Link from 'next/link' import { useCompany } from '@/contexts/CompanyContext' @@ -241,30 +242,38 @@ function CustomersPageInner() { - - - - - - {t('add_customer')} - - - - +
+ + + + + + + + {t('add_customer')} + + + + +
} /> diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 75639289..65ba72ad 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -39,6 +39,7 @@ 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 ArticlesEditStep from '@/components/import/ArticlesEditStep' import RegisterResultStep, { type RegisterResult } from '@/components/import/RegisterResultStep' import type { CustomerImportParseResult, @@ -50,6 +51,11 @@ import type { AnnotatedSupplierRow, DetectedSupplierColumns, } from '@/lib/import/suppliers/types' +import type { + ArticleImportParseResult, + AnnotatedArticleRow, + DetectedArticleColumns, +} from '@/lib/import/articles/types' // SIE import components import SIEUploadStep from '@/components/import/SIEUploadStep' @@ -1568,16 +1574,260 @@ function SuppliersFlow() { ) } +// ============================================================ +// Articles Flow (entity = "articles" inside CSVDataImportWizard) +// ============================================================ + +const ARTICLE_COLUMN_SPECS: RegisterColumnSpec[] = [ + { key: 'name_col', label: 'Benämning', required: true }, + { key: 'article_number_col', label: 'Artikelnummer', required: false }, + { key: 'type_col', label: 'Typ (vara/tjänst)', required: false }, + { key: 'unit_col', label: 'Enhet', required: false }, + { key: 'price_col', label: 'Pris exkl moms', required: false }, + { key: 'vat_rate_col', label: 'Moms (%)', required: false }, + { key: 'revenue_account_col', label: 'Försäljningskonto', required: false }, + { key: 'cost_price_col', label: 'Inköpspris', required: false }, + { key: 'ean_col', label: 'EAN', required: false }, + { key: 'housework_type_col', label: 'ROT/RUT-arbetstyp', required: false }, + { key: 'name_en_col', label: 'Benämning (engelska)', required: false }, + { key: 'notes_col', label: 'Anteckning', required: false }, +] + +function ArticlesFlow() { + const { toast } = useToast() + + const [step, setStep] = useState('upload') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [file, setFile] = useState(null) + const [parseResult, setParseResult] = useState(null) + const [executeResult, setExecuteResult] = useState(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/articles/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 ArticleImportParseResult + setParseResult(result) + + if (result.rows.length === 0) { + setError('Inga giltiga artiklar hittades. Kontrollera att filen innehåller en benämningskolumn.') + return + } + + toast({ + title: 'Fil analyserad', + description: `${result.rows.length} artiklar 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, + ) => { + if (!file) return + setIsLoading(true) + setError(null) + + try { + const overrides: DetectedArticleColumns = { + name_col: mapping.name_col ?? 0, + article_number_col: mapping.article_number_col, + name_en_col: mapping.name_en_col, + type_col: mapping.type_col, + unit_col: mapping.unit_col, + price_col: mapping.price_col, + vat_rate_col: mapping.vat_rate_col, + revenue_account_col: mapping.revenue_account_col, + cost_price_col: mapping.cost_price_col, + ean_col: mapping.ean_col, + housework_type_col: mapping.housework_type_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/articles/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: AnnotatedArticleRow[], + updateDuplicates: boolean, + ) => { + setIsLoading(true) + setError(null) + + try { + const res = await fetch('/api/import/articles/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rows: rows.map(({ duplicate_match: _dup, is_valid: _v, validation_errors: _ve, vat_rate_adjusted: _vra, ...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 ? 'Artiklar 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(parseResult.detected_columns as unknown as { [key: string]: unknown }, ARTICLE_COLUMN_SPECS) + : null + + return ( +
+ + +
+
+ + Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]} + + {steps.map((s, i) => ( + + {REGISTER_STEP_LABELS[s]} + + ))} +
+ +
+
+
+ + {step === 'upload' && ( + + )} + + {step === 'column_mapping' && parseResult && initialMapping && ( + + headers={parseResult.headers} + previewRows={parseResult.preview_rows} + specs={ARTICLE_COLUMN_SPECS} + initial={initialMapping} + onConfirm={handleColumnMappingConfirm} + onBack={() => setStep('upload')} + /> + )} + + {step === 'edit' && parseResult && ( + setStep(needsMapping ? 'column_mapping' : 'upload')} + isLoading={isLoading} + error={error} + /> + )} + + {step === 'result' && executeResult && ( + + )} +
+ ) +} + // ============================================================ // CSV/Excel Data Import Wizard — entity selector + sub-flow // ============================================================ -type CSVDataEntity = 'opening_balance' | 'customers' | 'suppliers' +type CSVDataEntity = 'opening_balance' | 'customers' | 'suppliers' | 'articles' const ENTITY_OPTIONS: { value: CSVDataEntity; label: string }[] = [ { value: 'opening_balance', label: 'Ingående balanser' }, { value: 'customers', label: 'Kunder' }, { value: 'suppliers', label: 'Leverantörer' }, + { value: 'articles', label: 'Artiklar' }, ] function CSVDataImportWizard() { @@ -1633,6 +1883,7 @@ function CSVDataImportWizard() { {entity === 'opening_balance' && } {entity === 'customers' && } {entity === 'suppliers' && } + {entity === 'articles' && } ) } @@ -2074,6 +2325,7 @@ export default function ImportPage() { { key: 'opening_balances', label: t('csv_chip_opening_balances') }, { key: 'customers', label: t('csv_chip_customers') }, { key: 'suppliers', label: t('csv_chip_suppliers') }, + { key: 'articles', label: t('csv_chip_articles') }, ].map(chip => ( {chip.label} diff --git a/app/(dashboard)/suppliers/page.tsx b/app/(dashboard)/suppliers/page.tsx index 58cfc892..364d4d93 100644 --- a/app/(dashboard)/suppliers/page.tsx +++ b/app/(dashboard)/suppliers/page.tsx @@ -8,6 +8,7 @@ import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { EmptyState } from '@/components/ui/empty-state' +import { ReportExportMenu } from '@/components/reports/ReportExportMenu' import { useToast } from '@/components/ui/use-toast' import { Plus, Search, Building2, Lock } from 'lucide-react' import SupplierForm from '@/components/suppliers/SupplierForm' @@ -118,30 +119,38 @@ export default function SuppliersPage() { {t('subtitle')}

- - - - - - - {t('add_supplier')} - - - - +
+ + + + + + + + {t('add_supplier')} + + + + +
{/* Search */} diff --git a/app/api/export/articles/__tests__/route.test.ts b/app/api/export/articles/__tests__/route.test.ts new file mode 100644 index 00000000..591121b9 --- /dev/null +++ b/app/api/export/articles/__tests__/route.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import * as XLSX from 'xlsx' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' +import { detectArticleColumns } from '@/lib/import/articles/column-detector' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const mockFetchAllRows = vi.fn() +vi.mock('@/lib/supabase/fetch-all', () => ({ + fetchAllRows: (...a: unknown[]) => mockFetchAllRows(...a), +})) + +import { GET } from '../route' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +const ARTICLE = { + id: 'a1', + article_number: '100', + name: 'Webdesign', + name_en: 'Web design', + type: 'tjanst', + unit: 'st', + price_excl_vat: 1200, + vat_rate: 25, + revenue_account: '3001', + cost_price: 400, + ean: '7350000000001', + housework_type: null, + notes: 'Kommentar med åäö', +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockFetchAllRows.mockResolvedValue([ARTICLE]) +}) + +describe('GET /api/export/articles', () => { + it('returns 401 when unauthenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + const res = await GET(createMockRequest('/api/export/articles')) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns an xlsx workbook whose headers round-trip through the importer', async () => { + enqueue({ data: { company_name: 'Acme AB' } }) + + const res = await GET(createMockRequest('/api/export/articles')) + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toContain('spreadsheetml') + const disposition = res.headers.get('Content-Disposition') || '' + expect(disposition).toContain('attachment') + expect(disposition).toContain('artiklar-acme-ab') + expect(disposition).toContain('.xlsx') + + const buf = Buffer.from(await res.arrayBuffer()) + expect(buf.length).toBeGreaterThan(0) + + const wb = XLSX.read(new Uint8Array(buf), { type: 'array' }) + const sheet = wb.Sheets[wb.SheetNames[0]] + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) + const headers = (rows[0] as string[]).map(String) + // Round-trip: the exported headers must re-detect with high confidence. + const detected = detectArticleColumns(headers) + expect(detected.confidence).toBeGreaterThanOrEqual(0.8) + expect(detected.name_col).toBeGreaterThanOrEqual(0) + expect(detected.price_col).not.toBeNull() + expect(detected.vat_rate_col).not.toBeNull() + expect(detected.revenue_account_col).not.toBeNull() + }) + + it('returns a UTF-8 BOM CSV when format=csv', async () => { + enqueue({ data: { company_name: 'Acme AB' } }) + + const res = await GET(createMockRequest('/api/export/articles', { searchParams: { format: 'csv' } })) + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toContain('text/csv') + expect(res.headers.get('Content-Disposition')).toContain('.csv') + + const buf = Buffer.from(await res.arrayBuffer()) + // UTF-8 BOM + expect([buf[0], buf[1], buf[2]]).toEqual([0xef, 0xbb, 0xbf]) + expect(buf.toString('utf-8')).toContain('Webdesign') + }) +}) diff --git a/app/api/export/articles/route.ts b/app/api/export/articles/route.ts new file mode 100644 index 00000000..bfd81e49 --- /dev/null +++ b/app/api/export/articles/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse } from '@/lib/errors/get-structured-error' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { textColumn, currencyColumn, integerColumn } from '@/lib/reports/xlsx-export' +import { buildRegisterExport, parseExportFormat, todayIso } from '@/lib/export/register-export' +import type { Article } from '@/types' + +/** + * GET /api/export/articles[?format=csv][&include_inactive=1] + * + * Downloads the article register as xlsx (default) or csv. Read-only — viewers + * may export. Column headers match the article importer's detector keywords so + * the file round-trips (export → edit → re-import). + */ +export const GET = withRouteContext( + 'article.export', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const url = new URL(request.url) + const format = parseExportFormat(url.searchParams.get('format')) + const includeInactive = url.searchParams.get('include_inactive') === '1' + + try { + const { data: companyRow } = await supabase + .from('company_settings') + .select('company_name') + .eq('company_id', companyId) + .single() + + const articles = (await fetchAllRows(({ from, to }) => { + let query = supabase + .from('articles') + .select('*') + .eq('company_id', companyId) + if (!includeInactive) query = query.eq('active', true) + return query.order('name', { ascending: true }).range(from, to) + })) as unknown as Article[] + + const { buffer, contentType, filename } = buildRegisterExport( + [ + { + name: 'Artiklar', + columns: [ + textColumn('Artikelnummer'), + textColumn('Benämning'), + textColumn('Benämning (engelska)'), + textColumn('Typ'), + textColumn('Enhet'), + currencyColumn('Försäljningspris'), + integerColumn('Moms %'), + textColumn('Försäljningskonto'), + currencyColumn('Inköpspris'), + textColumn('EAN'), + textColumn('ROT/RUT'), + textColumn('Anteckning'), + ], + rows: articles, + mapRow: (a) => [ + a.article_number, + a.name, + a.name_en, + a.type, + a.unit, + a.price_excl_vat, + a.vat_rate, + a.revenue_account, + a.cost_price, + a.ean, + a.housework_type, + a.notes, + ], + }, + ], + { format, slug: 'artiklar', companyName: companyRow?.company_name ?? '', date: todayIso() }, + ) + + // Audit trail: who exported what, when (sensitive bulk register download). + log.info('register exported', { entity: 'articles', format, rowCount: articles.length }) + + return new NextResponse(new Uint8Array(buffer), { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store', + }, + }) + } catch (err) { + log.error('article export failed', err as Error) + return errorResponse(err, log, { requestId }) + } + }, +) diff --git a/app/api/export/customers/__tests__/route.test.ts b/app/api/export/customers/__tests__/route.test.ts new file mode 100644 index 00000000..d8d59964 --- /dev/null +++ b/app/api/export/customers/__tests__/route.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import * as XLSX from 'xlsx' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const mockFetchAllRows = vi.fn() +vi.mock('@/lib/supabase/fetch-all', () => ({ + fetchAllRows: (...a: unknown[]) => mockFetchAllRows(...a), +})) + +import { GET } from '../route' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +const CUSTOMER = { + id: 'c1', + name: 'Acme AB', + customer_type: 'swedish_business', + org_number: '5560217780', + personal_number: null, + email: 'kontakt@acme.se', + phone: '0701234567', + address_line1: 'Storgatan 1', + address_line2: null, + postal_code: '11122', + city: 'Göteborg', + country: 'Sweden', + vat_number: 'SE556021778001', + default_payment_terms: 30, + notes: null, +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockFetchAllRows.mockResolvedValue([CUSTOMER]) +}) + +describe('GET /api/export/customers', () => { + it('returns 401 when unauthenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + const res = await GET(createMockRequest('/api/export/customers')) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns an xlsx customer register', async () => { + enqueue({ data: { company_name: 'Acme AB' } }) + const res = await GET(createMockRequest('/api/export/customers')) + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toContain('spreadsheetml') + expect(res.headers.get('Content-Disposition')).toContain('kunder-') + + const buf = Buffer.from(await res.arrayBuffer()) + const wb = XLSX.read(new Uint8Array(buf), { type: 'array' }) + const sheet = wb.Sheets[wb.SheetNames[0]] + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) + expect((rows[0] as string[])[0]).toBe('Namn') + expect((rows[1] as string[])).toContain('Acme AB') + }) + + it('returns a CSV with BOM when format=csv', async () => { + enqueue({ data: { company_name: 'Acme AB' } }) + const res = await GET(createMockRequest('/api/export/customers', { searchParams: { format: 'csv' } })) + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toContain('text/csv') + const buf = Buffer.from(await res.arrayBuffer()) + expect([buf[0], buf[1], buf[2]]).toEqual([0xef, 0xbb, 0xbf]) + expect(buf.toString('utf-8')).toContain('Göteborg') + }) +}) diff --git a/app/api/export/customers/route.ts b/app/api/export/customers/route.ts new file mode 100644 index 00000000..171ee61b --- /dev/null +++ b/app/api/export/customers/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse } from '@/lib/errors/get-structured-error' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { textColumn, integerColumn } from '@/lib/reports/xlsx-export' +import { buildRegisterExport, parseExportFormat, todayIso } from '@/lib/export/register-export' +import type { Customer } from '@/types' + +/** + * GET /api/export/customers[?format=csv] + * + * Downloads the customer register as xlsx (default) or csv. Read-only — viewers + * may export. Headers match the customer importer's detector keywords so files + * round-trip. + */ +export const GET = withRouteContext( + 'customer.export', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const format = parseExportFormat(new URL(request.url).searchParams.get('format')) + + try { + const { data: companyRow } = await supabase + .from('company_settings') + .select('company_name') + .eq('company_id', companyId) + .single() + + const customers = (await fetchAllRows(({ from, to }) => + supabase + .from('customers') + .select('*') + .eq('company_id', companyId) + .order('name', { ascending: true }) + .range(from, to), + )) as unknown as Customer[] + + const { buffer, contentType, filename } = buildRegisterExport( + [ + { + name: 'Kunder', + columns: [ + textColumn('Namn'), + textColumn('Org-/personnummer'), + textColumn('Kundtyp'), + textColumn('E-post'), + textColumn('Telefon'), + textColumn('Adress'), + textColumn('Adressrad 2'), + textColumn('Postnummer'), + textColumn('Ort'), + textColumn('Land'), + textColumn('VAT-nummer'), + integerColumn('Betalningsvillkor'), + textColumn('Anteckning'), + ], + rows: customers, + mapRow: (c) => [ + c.name, + c.org_number ?? c.personal_number, + c.customer_type, + c.email, + c.phone, + c.address_line1, + c.address_line2, + c.postal_code, + c.city, + c.country, + c.vat_number, + c.default_payment_terms, + c.notes, + ], + }, + ], + { format, slug: 'kunder', companyName: companyRow?.company_name ?? '', date: todayIso() }, + ) + + log.info('register exported', { entity: 'customers', format, rowCount: customers.length }) + + return new NextResponse(new Uint8Array(buffer), { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store', + }, + }) + } catch (err) { + log.error('customer export failed', err as Error) + return errorResponse(err, log, { requestId }) + } + }, +) diff --git a/app/api/export/suppliers/__tests__/route.test.ts b/app/api/export/suppliers/__tests__/route.test.ts new file mode 100644 index 00000000..4c5d4c84 --- /dev/null +++ b/app/api/export/suppliers/__tests__/route.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import * as XLSX from 'xlsx' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const mockFetchAllRows = vi.fn() +vi.mock('@/lib/supabase/fetch-all', () => ({ + fetchAllRows: (...a: unknown[]) => mockFetchAllRows(...a), +})) + +import { GET } from '../route' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +const SUPPLIER = { + id: 's1', + name: 'Leverantör AB', + supplier_type: 'swedish_business', + org_number: '5560217780', + vat_number: 'SE556021778001', + email: 'faktura@lev.se', + phone: null, + address_line1: null, + address_line2: null, + postal_code: null, + city: 'Malmö', + country: 'Sweden', + bankgiro: '5050-1055', + plusgiro: null, + bank_account: null, + iban: null, + bic: null, + default_payment_terms: 30, + default_currency: 'SEK', + notes: null, +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockFetchAllRows.mockResolvedValue([SUPPLIER]) +}) + +describe('GET /api/export/suppliers', () => { + it('returns 401 when unauthenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + const res = await GET(createMockRequest('/api/export/suppliers')) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns an xlsx supplier register with banking columns', async () => { + enqueue({ data: { company_name: 'Acme AB' } }) + const res = await GET(createMockRequest('/api/export/suppliers')) + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Disposition')).toContain('leverantorer-') + + const buf = Buffer.from(await res.arrayBuffer()) + const wb = XLSX.read(new Uint8Array(buf), { type: 'array' }) + const sheet = wb.Sheets[wb.SheetNames[0]] + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) + const headers = (rows[0] as string[]).map(String) + expect(headers).toContain('Bankgiro') + expect(headers).toContain('Valuta') + expect((rows[1] as string[])).toContain('Leverantör AB') + }) +}) diff --git a/app/api/export/suppliers/route.ts b/app/api/export/suppliers/route.ts new file mode 100644 index 00000000..3dbd387f --- /dev/null +++ b/app/api/export/suppliers/route.ts @@ -0,0 +1,105 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse } from '@/lib/errors/get-structured-error' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { textColumn, integerColumn } from '@/lib/reports/xlsx-export' +import { buildRegisterExport, parseExportFormat, todayIso } from '@/lib/export/register-export' +import type { Supplier } from '@/types' + +/** + * GET /api/export/suppliers[?format=csv] + * + * Downloads the supplier register as xlsx (default) or csv. Read-only — viewers + * may export. Headers match the supplier importer's detector keywords so files + * round-trip. + */ +export const GET = withRouteContext( + 'supplier.export', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const format = parseExportFormat(new URL(request.url).searchParams.get('format')) + + try { + const { data: companyRow } = await supabase + .from('company_settings') + .select('company_name') + .eq('company_id', companyId) + .single() + + const suppliers = (await fetchAllRows(({ from, to }) => + supabase + .from('suppliers') + .select('*') + .eq('company_id', companyId) + .order('name', { ascending: true }) + .range(from, to), + )) as unknown as Supplier[] + + const { buffer, contentType, filename } = buildRegisterExport( + [ + { + name: 'Leverantörer', + columns: [ + textColumn('Namn'), + textColumn('Org-/personnummer'), + textColumn('Leverantörstyp'), + textColumn('E-post'), + textColumn('Telefon'), + textColumn('Adress'), + textColumn('Adressrad 2'), + textColumn('Postnummer'), + textColumn('Ort'), + textColumn('Land'), + textColumn('VAT-nummer'), + textColumn('Bankgiro'), + textColumn('Plusgiro'), + textColumn('Bankkonto'), + textColumn('IBAN'), + textColumn('BIC'), + integerColumn('Betalningsvillkor'), + textColumn('Valuta'), + textColumn('Anteckning'), + ], + rows: suppliers, + mapRow: (s) => [ + s.name, + s.org_number, + s.supplier_type, + s.email, + s.phone, + s.address_line1, + s.address_line2, + s.postal_code, + s.city, + s.country, + s.vat_number, + s.bankgiro, + s.plusgiro, + s.bank_account, + s.iban, + s.bic, + s.default_payment_terms, + s.default_currency, + s.notes, + ], + }, + ], + { format, slug: 'leverantorer', companyName: companyRow?.company_name ?? '', date: todayIso() }, + ) + + log.info('register exported', { entity: 'suppliers', format, rowCount: suppliers.length }) + + return new NextResponse(new Uint8Array(buffer), { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store', + }, + }) + } catch (err) { + log.error('supplier export failed', err as Error) + return errorResponse(err, log, { requestId }) + } + }, +) diff --git a/app/api/import/articles/__tests__/execute.test.ts b/app/api/import/articles/__tests__/execute.test.ts new file mode 100644 index 00000000..303adf29 --- /dev/null +++ b/app/api/import/articles/__tests__/execute.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const mockEmit = vi.fn().mockResolvedValue(undefined) +vi.mock('@/lib/events', () => ({ eventBus: { emit: (...a: unknown[]) => mockEmit(...a) } })) + +const mockFetchAllRows = vi.fn() +vi.mock('@/lib/supabase/fetch-all', () => ({ + fetchAllRows: (...a: unknown[]) => mockFetchAllRows(...a), +})) + +const mockEnsureArticleNumber = vi.fn().mockResolvedValue('AUTO-1') +vi.mock('@/lib/articles/ensure-article-number', () => ({ + ensureArticleNumber: (...a: unknown[]) => mockEnsureArticleNumber(...a), +})) + +const mockCheckRevenueAccount = vi.fn().mockResolvedValue('ok') +vi.mock('@/lib/articles/validate-revenue-account', () => ({ + checkRevenueAccount: (...a: unknown[]) => mockCheckRevenueAccount(...a), +})) + +import { POST } from '../execute/route' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function row(overrides: Record = {}) { + return { + row_index: 2, + name: 'Konsulttimme', + name_en: null, + article_number: null, + type: 'tjanst', + unit: 'tim', + price_excl_vat: 950, + vat_rate: 25, + revenue_account: null, + cost_price: null, + ean: null, + housework_type: null, + notes: null, + ...overrides, + } +} + +function makeRequest(body: unknown) { + return createMockRequest('/api/import/articles/execute', { method: 'POST', body }) +} + +describe('POST /api/import/articles/execute', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockFetchAllRows.mockResolvedValue([]) + mockCheckRevenueAccount.mockResolvedValue('ok') + mockEnsureArticleNumber.mockResolvedValue('AUTO-1') + }) + + it('returns 401 for unauthenticated requests', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + const res = await POST(makeRequest({ rows: [row()], update_duplicates: false })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns 400 for an empty rows array', async () => { + const res = await POST(makeRequest({ rows: [], update_duplicates: false })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(400) + }) + + it('creates new articles and emits article.created', async () => { + enqueue({ data: { id: 'a1', name: 'Konsulttimme', article_number: null } }) + enqueue({ data: { id: 'a2', name: 'Skruv', article_number: 'A-200' } }) + + const res = await POST(makeRequest({ + rows: [row(), row({ row_index: 3, name: 'Skruv', article_number: 'A-200', type: 'vara' })], + update_duplicates: false, + })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body.data.created).toBe(2) + expect(body.data.failed).toBe(0) + expect(mockEmit).toHaveBeenCalledTimes(2) + // The numberless row gets auto-numbered; the one with A-200 does not. + expect(mockEnsureArticleNumber).toHaveBeenCalledTimes(1) + }) + + it('skips a duplicate matched by article number when update_duplicates is false', async () => { + mockFetchAllRows.mockResolvedValue([{ id: 'x', name: 'Existing', article_number: 'A-1' }]) + + const res = await POST(makeRequest({ + rows: [row({ article_number: 'A-1' })], + update_duplicates: false, + })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body.data.skipped).toBe(1) + expect(body.data.created).toBe(0) + }) + + it('updates a duplicate matched by article number when update_duplicates is true', async () => { + mockFetchAllRows.mockResolvedValue([{ id: 'x', name: 'Old', article_number: 'A-1' }]) + enqueue({ data: { id: 'x', name: 'New name', article_number: 'A-1' } }) + + const res = await POST(makeRequest({ + rows: [row({ article_number: 'A-1', name: 'New name' })], + update_duplicates: true, + })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body.data.updated).toBe(1) + expect(body.data.created).toBe(0) + }) + + it('matches a duplicate by name (case-insensitive)', async () => { + mockFetchAllRows.mockResolvedValue([{ id: 'x', name: 'Konsulttimme', article_number: null }]) + + const res = await POST(makeRequest({ + rows: [row({ name: 'KONSULTTIMME' })], + update_duplicates: false, + })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body.data.skipped).toBe(1) + }) + + it('treats a 23505 unique violation as a soft skip', async () => { + enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } }) + + const res = await POST(makeRequest({ + rows: [row({ article_number: 'A-DUP' })], + update_duplicates: false, + })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body.data.skipped).toBe(1) + expect(body.data.failed).toBe(0) + }) + + it('drops an inactive/unknown revenue account and records a warning', async () => { + mockCheckRevenueAccount.mockResolvedValue('activatable') + enqueue({ data: { id: 'a1', name: 'Konsulttimme', article_number: 'A-1' } }) + + const res = await POST(makeRequest({ + rows: [row({ article_number: 'A-1', revenue_account: '3999' })], + update_duplicates: false, + })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body.data.created).toBe(1) + expect(body.data.warnings.length).toBeGreaterThan(0) + expect(body.data.warnings[0]).toContain('3999') + }) +}) diff --git a/app/api/import/articles/execute/route.ts b/app/api/import/articles/execute/route.ts new file mode 100644 index 00000000..dc63d21f --- /dev/null +++ b/app/api/import/articles/execute/route.ts @@ -0,0 +1,233 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { eventBus } from '@/lib/events' +import { validateBody } from '@/lib/api/validate' +import { ArticleImportExecuteSchema } from '@/lib/api/schemas' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { ensureArticleNumber } from '@/lib/articles/ensure-article-number' +import { checkRevenueAccount, type RevenueAccountStatus } from '@/lib/articles/validate-revenue-account' +import type { Article } from '@/types' +import type { ArticleImportExecuteResult } from '@/lib/import/articles/types' + +ensureInitialized() + +interface ExistingArticle { + id: string + name: string + article_number: string | null +} + +function nameKey(value: string | null): string | null { + if (!value) return null + return value.trim().toLowerCase() || null +} + +/** + * POST /api/import/articles/execute + * + * Imports validated article rows. Duplicates (matched by article number, then + * by name) are either updated (merge — only non-empty fields overwrite) or + * skipped based on `update_duplicates`. An optional BAS revenue-account override + * is kept only when it is an active class-3 account; unknown/inactive accounts + * are dropped (with a warning) rather than mutating the chart of accounts. + */ +export const POST = withRouteContext( + 'register_import.articles.execute', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx + + const result = await validateBody(request, ArticleImportExecuteSchema, { + log, + operation: 'register_import.articles.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('articles') + .select('id, name, article_number') + .eq('company_id', companyId) + .range(from, to), + ) + const existing = existingRaw as unknown as ExistingArticle[] + + const byNumber = new Map() + const byName = new Map() + for (const a of existing) { + if (a.article_number) byNumber.set(a.article_number, a) + const nk = nameKey(a.name) + if (nk && !byName.has(nk)) byName.set(nk, a) + } + + // Revenue-account validation is cached per distinct account so a large + // import doesn't re-query the chart for every row. + const accountStatusCache = new Map() + const droppedAccounts = new Set() + const warnings: string[] = [] + const resolveRevenueAccount = async (acc: string | null): Promise => { + if (!acc) return null + let status = accountStatusCache.get(acc) + if (!status) { + status = await checkRevenueAccount(supabase, companyId!, acc) + accountStatusCache.set(acc, status) + } + if (status === 'ok') return acc + if (!droppedAccounts.has(acc)) { + droppedAccounts.add(acc) + warnings.push( + status === 'activatable' + ? `Försäljningskonto ${acc} är inte aktiverat i kontoplanen — artiklar importerades utan kontoöverstyrning.` + : `Försäljningskonto ${acc} är ogiltigt — ignorerades.`, + ) + } + return null + } + + const created: Article[] = [] + const updated: Article[] = [] + let skipped = 0 + const errors: { row_index: number; name: string; reason: string }[] = [] + + for (const row of rows) { + const nk = nameKey(row.name) + const match = + (row.article_number ? byNumber.get(row.article_number) : undefined) ?? + (nk ? byName.get(nk) : undefined) ?? + null + + const revenueAccount = await resolveRevenueAccount(row.revenue_account) + + if (match) { + if (!update_duplicates) { + skipped++ + continue + } + + // Merge mode: overwrite only fields the file clearly carries a value + // for. type/unit/vat_rate carry parser defaults that can't be told + // apart from "absent", so they are left untouched to avoid clobbering. + const merged: Record = {} + if (row.name) merged.name = row.name + if (row.name_en) merged.name_en = row.name_en + if (row.price_excl_vat > 0) merged.price_excl_vat = row.price_excl_vat + if (row.cost_price !== null) merged.cost_price = row.cost_price + if (row.ean) merged.ean = row.ean + if (row.housework_type) merged.housework_type = row.housework_type + if (row.notes) merged.notes = row.notes + if (revenueAccount) merged.revenue_account = revenueAccount + + if (Object.keys(merged).length === 0) { + skipped++ + continue + } + + const { data, error } = await supabase + .from('articles') + .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 Article) + continue + } + + // No match — create. + const { data, error } = await supabase + .from('articles') + .insert({ + user_id: user.id, + company_id: companyId, + name: row.name, + name_en: row.name_en, + type: row.type, + unit: row.unit || 'st', + price_excl_vat: row.price_excl_vat, + vat_rate: row.vat_rate, + revenue_account: revenueAccount, + cost_price: row.cost_price, + ean: row.ean, + housework_type: row.housework_type, + notes: row.notes, + article_number: row.article_number, + }) + .select() + .single() + + if (error) { + // Unique violation on (company_id, article_number) — treat as a soft + // skip (manual number collided with an existing or in-batch article). + if (error.code === '23505') { + skipped++ + continue + } + errors.push({ row_index: row.row_index, name: row.name, reason: error.message }) + continue + } + + if (data) { + // Auto-number when the file didn't supply one. Non-fatal: an + // unnumbered article is still usable and can be numbered later. + if (!data.article_number) { + try { + data.article_number = await ensureArticleNumber(supabase, companyId!, data.id) + } catch (err) { + opLog.warn('article number assignment failed', err as Error, { articleId: data.id }) + } + } + created.push(data as Article) + // Track newly inserted number + name so later rows in the same batch + // dedup against them too. + const newArticle = data as ExistingArticle + if (newArticle.article_number) byNumber.set(newArticle.article_number, newArticle) + const nk = nameKey(newArticle.name) + if (nk && !byName.has(nk)) byName.set(nk, newArticle) + } + } + + // Emit events for downstream listeners (non-blocking). + for (const a of created) { + await eventBus.emit({ + type: 'article.created', + payload: { article: a, companyId: companyId!, userId: user.id }, + }) + } + + const response: ArticleImportExecuteResult = { + success: errors.length === 0, + created: created.length, + updated: updated.length, + skipped, + failed: errors.length, + errors, + warnings, + } + + opLog.info('article import complete', response) + + return NextResponse.json({ data: response }) + } catch (err) { + opLog.error('article import execute failed', err as Error) + return errorResponseFromCode('REG_IMPORT_EXECUTE_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, + { requireWrite: true }, +) diff --git a/app/api/import/articles/parse/route.ts b/app/api/import/articles/parse/route.ts new file mode 100644 index 00000000..8fb52df4 --- /dev/null +++ b/app/api/import/articles/parse/route.ts @@ -0,0 +1,134 @@ +import { NextResponse } from 'next/server' +import { parseArticlesFile } from '@/lib/import/articles/parser' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { ArticleColumnOverridesSchema } from '@/lib/api/schemas' +import type { + AnnotatedArticleRow, + ArticleImportParseResult, + DetectedArticleColumns, +} from '@/lib/import/articles/types' + +const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods'] +const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB + +/** Lowercased dedup key for matching an article by name. */ +function nameKey(value: string | null): string | null { + if (!value) return null + return value.trim().toLowerCase() || null +} + +/** + * POST /api/import/articles/parse + * + * Accepts an Excel/CSV file via FormData, auto-detects columns, parses rows, + * and annotates each row with any duplicate-match against existing articles + * (by article number first, then by name). + */ +export const POST = withRouteContext( + 'register_import.articles.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: DetectedArticleColumns | undefined + if (columnOverridesRaw) { + let raw: unknown + try { + raw = JSON.parse(columnOverridesRaw) + } catch { + return errorResponseFromCode('REG_IMPORT_INVALID_COLUMN_OVERRIDES', opLog, { requestId }) + } + // Validate shape/indices before trusting it to drive the parser. + const parsed = ArticleColumnOverridesSchema.safeParse(raw) + if (!parsed.success) { + return errorResponseFromCode('REG_IMPORT_INVALID_COLUMN_OVERRIDES', opLog, { requestId }) + } + columnOverrides = parsed.data + } + + try { + const buffer = await file.arrayBuffer() + const parsed = parseArticlesFile(buffer, file.name, columnOverrides) + + // Fetch existing articles for duplicate detection. + const existing = await fetchAllRows(({ from, to }) => + supabase + .from('articles') + .select('id, name, article_number') + .eq('company_id', companyId) + .range(from, to), + ) + + const byNumber = new Map() + const byName = new Map() + for (const a of existing) { + if (a.article_number) byNumber.set(String(a.article_number), { id: a.id, name: a.name }) + const nk = nameKey(a.name) + if (nk && !byName.has(nk)) byName.set(nk, { id: a.id, name: a.name }) + } + + let duplicateCount = 0 + const annotated: AnnotatedArticleRow[] = parsed.rows.map((r) => { + let match: AnnotatedArticleRow['duplicate_match'] = null + if (r.article_number && byNumber.has(r.article_number)) { + const e = byNumber.get(r.article_number)! + match = { article_id: e.id, matched_by: 'article_number', existing_name: e.name } + } else { + const nk = nameKey(r.name) + if (nk && byName.has(nk)) { + const e = byName.get(nk)! + match = { article_id: e.id, matched_by: 'name', existing_name: e.name } + } + } + if (match) duplicateCount++ + return { ...r, duplicate_match: match } + }) + + const result: ArticleImportParseResult = { + 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('article import parse failed', err as Error) + return errorResponseFromCode('REG_IMPORT_PARSE_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) diff --git a/components/import/ArticlesEditStep.tsx b/components/import/ArticlesEditStep.tsx new file mode 100644 index 00000000..1fe5329a --- /dev/null +++ b/components/import/ArticlesEditStep.tsx @@ -0,0 +1,308 @@ +'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 { ArticleType } from '@/types' +import type { AnnotatedArticleRow } from '@/lib/import/articles/types' + +let idCounter = 0 +const newId = () => `art_row_${++idCounter}_${Date.now()}` + +interface EditableArticleRow extends AnnotatedArticleRow { + id: string +} + +interface ArticlesEditStepProps { + rows: AnnotatedArticleRow[] + onExecute: (rows: AnnotatedArticleRow[], updateDuplicates: boolean) => void + onBack: () => void + isLoading: boolean + error: string | null +} + +const TYPE_LABELS: Record = { + vara: 'Vara', + tjanst: 'Tjänst', +} + +const VAT_RATES = [25, 12, 6, 0] as const + +export default function ArticlesEditStep({ + rows: initialRows, + onExecute, + onBack, + isLoading, + error, +}: ArticlesEditStepProps) { + const [rows, setRows] = useState(() => + 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 adjustedVatCount = useMemo( + () => rows.filter((r) => r.vat_rate_adjusted).length, + [rows], + ) + + const canContinue = rows.length > 0 && !hasErrors && !isLoading + + const updateRow = useCallback((id: string, updates: Partial) => { + 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 handlePriceChange = useCallback((id: string, raw: string) => { + const n = parseFloat(raw.replace(',', '.')) + const price = Number.isFinite(n) ? n : 0 + updateRow(id, { + price_excl_vat: price, + is_valid: price >= 0, + validation_errors: price < 0 ? ['Priset kan inte vara negativt'] : [], + }) + }, [updateRow]) + + const handleExecute = () => { + if (!canContinue) return + const stripped: AnnotatedArticleRow[] = rows.map(({ id: _id, ...rest }) => rest) + onExecute(stripped, updateDuplicates) + } + + return ( + + + Granska artiklar + + Kontrollera att uppgifterna stämmer. Du kan justera benämning, typ, pris och moms + inline, eller ta bort rader. {newCount} ny{newCount === 1 ? '' : 'a'} artik{newCount === 1 ? 'el' : 'lar'} skapas + {liveDuplicateCount > 0 ? ` och ${liveDuplicateCount} matchar befintliga.` : '.'} + + + + {/* Duplicate handling banner */} + {liveDuplicateCount > 0 && ( +
+ +
+

+ {liveDuplicateCount} rader matchar befintliga + artiklar (på artikelnummer eller benämning). +

+
+ + +
+ {updateDuplicates && ( +

+ Endast fält med värden i filen skrivs över. Typ, enhet och moms lämnas orörda. +

+ )} +
+
+ )} + + {/* VAT-adjustment notice — rows whose rate was snapped/defaulted */} + {adjustedVatCount > 0 && ( +
+ +

+ {adjustedVatCount} rad{adjustedVatCount === 1 ? '' : 'er'} fick + momssatsen omtolkad (avrundad till närmaste giltiga, eller satt till 25 %). + Kontrollera de markerade raderna innan du importerar — fel momssats ger fel moms på fakturan. +

+
+ )} + + {/* Table */} +
+ + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
Art.nrBenämningTypPris exkl momsMomsStatus +
+ {row.article_number || 'Auto'} + + updateRow(row.id, { name: e.target.value })} + className="h-8" + /> + + + + handlePriceChange(row.id, e.target.value)} + className="h-8 text-right tabular-nums" + /> + +
+ + {row.vat_rate_adjusted && ( + + + + )} +
+
+
+ {!row.is_valid && ( + + + + )} + {row.duplicate_match ? ( + + {updateDuplicates ? 'Uppdateras' : 'Hoppas över'} + + ) : ( + + Ny + + )} +
+
+ +
+
+ + {hasErrors && ( +
+ +

+ Vissa rader har valideringsfel (markerade i rött). Åtgärda eller ta bort dem + innan du fortsätter. +

+
+ )} + + {error && ( +
+ +

{error}

+
+ )} + +
+ + +
+
+
+ ) +} diff --git a/components/import/RegisterResultStep.tsx b/components/import/RegisterResultStep.tsx index b9f8cbfa..0b2173cb 100644 --- a/components/import/RegisterResultStep.tsx +++ b/components/import/RegisterResultStep.tsx @@ -12,10 +12,12 @@ export type RegisterResult = { skipped: number failed: number errors: { row_index: number; name: string; reason: string }[] + /** Non-fatal notes (e.g. dropped revenue-account overrides on article import). */ + warnings?: string[] } interface RegisterResultStepProps { - entity: 'customers' | 'suppliers' + entity: 'customers' | 'suppliers' | 'articles' result: RegisterResult onNewImport: () => void } @@ -33,6 +35,12 @@ const ENTITY_COPY = { listLabel: 'Visa alla leverantörer', listHref: '/suppliers', }, + articles: { + successTitle: 'Artiklar importerade', + failTitle: 'Importen misslyckades', + listLabel: 'Visa alla artiklar', + listHref: '/articles', + }, } as const export default function RegisterResultStep({ @@ -94,6 +102,23 @@ export default function RegisterResultStep({ )} + {/* Warnings (non-fatal — e.g. dropped revenue-account overrides) */} + {result.warnings && result.warnings.length > 0 && ( +
+

Att notera

+
+
    + {result.warnings.map((w, i) => ( +
  • + + {w} +
  • + ))} +
+
+
+ )} +