Fixed user issues (#559)

* Fixed user issues

* feat: add personal_number column to customers for individual identification

* feat: add personal_number field to makeCustomer function for enhanced customer identification

* feat: add personal_number column with constraint check for customer identification
This commit is contained in:
Mattsson
2026-05-22 12:22:15 +02:00
committed by GitHub
parent b34de598e3
commit 64bbeb4021
25 changed files with 1376 additions and 133 deletions
+260 -38
View File
@@ -1,19 +1,30 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo, useCallback, Suspense } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { Plus, Search, Users, Lock } from 'lucide-react'
import { Plus, Search, Users, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import CustomerForm from '@/components/customers/CustomerForm'
import { EmptyCustomers, EmptyState } from '@/components/ui/empty-state'
import { PageHeader } from '@/components/ui/page-header'
import { formatDate } from '@/lib/utils'
import Link from 'next/link'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -26,6 +37,18 @@ const CUSTOMER_TYPE_LABEL_KEYS: Record<CustomerType, string> = {
non_eu_business: 'type_non_eu_business',
}
type SortColumn = 'name' | 'customer_type' | 'identifier' | 'email' | 'city' | 'created_at'
type SortDir = 'asc' | 'desc'
const SORTABLE_COLUMNS: ReadonlyArray<SortColumn> = [
'name',
'customer_type',
'identifier',
'email',
'city',
'created_at',
]
function getInitials(name: string): string {
return name
.split(' ')
@@ -35,7 +58,15 @@ function getInitials(name: string): string {
.toUpperCase()
}
export default function CustomersPage() {
function getIdentifier(customer: Customer): string {
return customer.org_number || customer.personal_number || ''
}
function compareStrings(a: string, b: string): number {
return a.localeCompare(b, 'sv', { sensitivity: 'base' })
}
function CustomersPageInner() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
const [customers, setCustomers] = useState<Customer[]>([])
@@ -48,6 +79,31 @@ export default function CustomersPage() {
const t = useTranslations('customers')
const errorLocale = useLocale() as ErrorLocale
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const sortParam = searchParams.get('sort')
const dirParam = searchParams.get('dir')
const sortColumn: SortColumn = (SORTABLE_COLUMNS as ReadonlyArray<string>).includes(sortParam ?? '')
? (sortParam as SortColumn)
: 'name'
const sortDir: SortDir = dirParam === 'desc' ? 'desc' : 'asc'
const updateSort = useCallback(
(column: SortColumn) => {
const params = new URLSearchParams(searchParams.toString())
let nextDir: SortDir = 'asc'
if (column === sortColumn) {
nextDir = sortDir === 'asc' ? 'desc' : 'asc'
}
params.set('sort', column)
params.set('dir', nextDir)
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
},
[searchParams, sortColumn, sortDir, router, pathname]
)
async function fetchCustomers() {
if (!company) return
setIsLoading(true)
@@ -71,6 +127,7 @@ export default function CustomersPage() {
useEffect(() => {
fetchCustomers()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function handleCreateCustomer(data: CreateCustomerInput) {
@@ -102,11 +159,82 @@ export default function CustomersPage() {
setIsCreating(false)
}
const filteredCustomers = customers.filter((customer) =>
customer.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
customer.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
customer.org_number?.includes(searchTerm)
)
const filteredCustomers = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
if (!term) return customers
return customers.filter((c) => {
return (
c.name.toLowerCase().includes(term) ||
c.email?.toLowerCase().includes(term) ||
c.org_number?.includes(term) ||
c.personal_number?.includes(term) ||
c.city?.toLowerCase().includes(term) ||
c.notes?.toLowerCase().includes(term)
)
})
}, [customers, searchTerm])
const sortedCustomers = useMemo(() => {
const arr = [...filteredCustomers]
arr.sort((a, b) => {
let av = ''
let bv = ''
switch (sortColumn) {
case 'name':
av = a.name || ''
bv = b.name || ''
break
case 'customer_type':
av = a.customer_type || ''
bv = b.customer_type || ''
break
case 'identifier':
av = getIdentifier(a)
bv = getIdentifier(b)
break
case 'email':
av = a.email || ''
bv = b.email || ''
break
case 'city':
av = a.city || ''
bv = b.city || ''
break
case 'created_at':
av = a.created_at || ''
bv = b.created_at || ''
break
}
const cmp = compareStrings(av, bv)
return sortDir === 'asc' ? cmp : -cmp
})
return arr
}, [filteredCustomers, sortColumn, sortDir])
function SortableHeader({
column,
label,
className,
}: {
column: SortColumn
label: string
className?: string
}) {
const isActive = sortColumn === column
const Icon = isActive ? (sortDir === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown
return (
<TableHead className={className}>
<button
type="button"
onClick={() => updateSort(column)}
className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
>
{label}
<Icon className="h-3 w-3 opacity-70" aria-hidden="true" />
</button>
</TableHead>
)
}
return (
<div className="space-y-8">
@@ -153,20 +281,31 @@ export default function CustomersPage() {
{/* Customer list */}
{isLoading ? (
<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>
<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" />
</CardContent>
</Card>
))}
</div>
) : filteredCustomers.length === 0 ? (
<>
{/* Desktop skeleton */}
<Card className="hidden md:block">
<CardContent className="p-6 space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
{/* Mobile skeleton */}
<div className="grid gap-4 md:hidden">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-4 w-1/3 mt-2" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
))}
</div>
</>
) : sortedCustomers.length === 0 ? (
<Card>
<CardContent className="p-0">
{searchTerm ? (
@@ -181,17 +320,89 @@ export default function CustomersPage() {
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredCustomers.map((customer) => (
<>
{/* Desktop table */}
<Card className="hidden md:block">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<SortableHeader column="name" label={t('col_name')} />
<SortableHeader column="customer_type" label={t('col_type')} />
<SortableHeader column="identifier" label={t('col_identifier')} />
<SortableHeader column="email" label={t('col_email')} />
<SortableHeader column="city" label={t('col_city')} />
<SortableHeader
column="created_at"
label={t('col_created')}
className="text-right"
/>
</TableRow>
</TableHeader>
<TableBody>
{sortedCustomers.map((customer) => {
const identifier = getIdentifier(customer)
return (
<TableRow
key={customer.id}
className="cursor-pointer"
onClick={() => router.push(`/customers/${customer.id}`)}
>
<TableCell className="font-medium">
<Link
href={`/customers/${customer.id}`}
className="hover:text-primary transition-colors"
onClick={(e) => e.stopPropagation()}
>
{customer.name}
</Link>
</TableCell>
<TableCell>
<Badge variant="secondary">
{t(CUSTOMER_TYPE_LABEL_KEYS[customer.customer_type])}
</Badge>
</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
<div className="flex items-center gap-2">
<span>{identifier || '—'}</span>
{customer.org_number && customer.vat_number_validated && (
<Badge variant="success" className="text-xs">
{t('verified')}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground truncate max-w-[220px]">
{customer.email || '—'}
</TableCell>
<TableCell className="text-muted-foreground">
{customer.city || '—'}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatDate(customer.created_at)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Mobile card list */}
<div className="grid gap-4 md:hidden">
{sortedCustomers.map((customer) => (
<Link key={customer.id} href={`/customers/${customer.id}`}>
<Card className="cursor-pointer transition-all duration-150 hover:border-primary/50 hover:bg-accent/50 hover:shadow-sm motion-safe:active:scale-[0.99] active:shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 h-full group">
<Card className="cursor-pointer transition-colors duration-150 hover:bg-secondary/60 h-full group">
<CardHeader className="pb-3">
<div className="flex items-center gap-3.5">
<div className="h-11 w-11 rounded-full bg-primary/10 flex items-center justify-center shrink-0 text-sm font-semibold text-primary tracking-tight">
<div className="h-11 w-11 rounded-full bg-secondary flex items-center justify-center shrink-0 text-sm font-semibold tracking-tight">
{getInitials(customer.name)}
</div>
<div className="min-w-0">
<CardTitle className="text-base truncate group-hover:text-primary transition-colors">{customer.name}</CardTitle>
<CardTitle className="text-base truncate group-hover:text-primary transition-colors">
{customer.name}
</CardTitle>
<Badge variant="secondary" className="mt-1">
{t(CUSTOMER_TYPE_LABEL_KEYS[customer.customer_type])}
</Badge>
@@ -200,27 +411,38 @@ export default function CustomersPage() {
</CardHeader>
<CardContent>
<div className="space-y-1.5 text-sm text-muted-foreground">
{customer.email && (
<p className="truncate">{customer.email}</p>
)}
{customer.org_number && (
<div className="flex items-center gap-2">
<span>{customer.org_number}</span>
{customer.vat_number_validated && (
<Badge variant="success" className="text-xs">{t('verified')}</Badge>
{customer.email && <p className="truncate">{customer.email}</p>}
{getIdentifier(customer) && (
<div className="flex items-center gap-2 tabular-nums">
<span>{getIdentifier(customer)}</span>
{customer.org_number && customer.vat_number_validated && (
<Badge variant="success" className="text-xs">
{t('verified')}
</Badge>
)}
</div>
)}
{customer.city && (
<p>{customer.city}, {customer.country}</p>
<p>
{customer.city}, {customer.country}
</p>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
))}
</div>
</>
)}
</div>
)
}
export default function CustomersPage() {
return (
<Suspense fallback={null}>
<CustomersPageInner />
</Suspense>
)
}
+92 -5
View File
@@ -1,14 +1,15 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useSearchParams, useRouter } from 'next/navigation'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
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, FileSpreadsheet } from 'lucide-react'
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet, Download } from 'lucide-react'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
@@ -70,6 +71,8 @@ import type {
import type { BASAccount } from '@/types'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import dynamic from 'next/dynamic'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import CloudBackupCard from '@/extensions/general/cloud-backup/components/CloudBackupCard'
const MigrationWizard = dynamic(
() => import('@/components/extensions/general/ArcimMigrationWorkspace'),
@@ -1728,9 +1731,13 @@ type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'csv_data' | 'migration'
export default function ImportPage() {
const { company } = useCompany()
const [mode, setMode] = useState<ImportMode>(null)
const [view, setView] = useState<'import' | 'export'>('import')
const [userId, setUserId] = useState('')
const [isSandbox, setIsSandbox] = useState(false)
const [exportPeriodId, setExportPeriodId] = useState<string | null>(null)
const t = useTranslations('import')
const router = useRouter()
const hasCloudBackup = ENABLED_EXTENSION_IDS.has('cloud-backup')
// Fetch authenticated user ID and sandbox status
useEffect(() => {
@@ -1750,7 +1757,7 @@ export default function ImportPage() {
})
}, [])
// Sync mode from URL search params (reacts to client-side navigation changes)
// Sync mode + view from URL search params (reacts to client-side navigation changes)
const searchParams = useSearchParams()
useEffect(() => {
if (isSandbox) return
@@ -1762,7 +1769,33 @@ export default function ImportPage() {
setMode(modeParam as ImportMode)
}
}
const viewParam = searchParams.get('view')
if (viewParam === 'export' || viewParam === 'import') {
setView(viewParam)
}
}, [isSandbox, searchParams])
// Hash-based deep links (#cloud-backup, #sie-export) → switch to export tab and scroll
useEffect(() => {
if (typeof window === 'undefined') return
const hash = window.location.hash
if (hash === '#cloud-backup' || hash === '#sie-export') {
setView('export')
setTimeout(() => {
document.querySelector(hash)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
}, 50)
}
}, [])
const handleViewChange = (next: string) => {
if (next !== 'import' && next !== 'export') return
setView(next)
const params = new URLSearchParams(searchParams.toString())
if (next === 'export') params.set('view', 'export')
else params.delete('view')
const qs = params.toString()
router.replace(qs ? `/import?${qs}` : '/import', { scroll: false })
}
// Extensions are active if compiled in — no runtime toggle check needed
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasMigrationExtension = ENABLED_EXTENSION_IDS.has('arcim-migration')
@@ -1788,7 +1821,14 @@ export default function ImportPage() {
</div>
)}
<div className="space-y-2">
<Tabs value={view} onValueChange={handleViewChange}>
<TabsList className="grid w-full max-w-xs grid-cols-2">
<TabsTrigger value="import">{t('tab_import')}</TabsTrigger>
<TabsTrigger value="export">{t('tab_export')}</TabsTrigger>
</TabsList>
<TabsContent value="import" className="mt-6">
<div className="space-y-2">
{/* 1. Koppla bank */}
{hasBankingExtension && (
<div
@@ -1970,7 +2010,54 @@ export default function ImportPage() {
</div>
<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>
</div>
</div>
</TabsContent>
<TabsContent value="export" className="mt-6">
<div className="grid gap-4 md:grid-cols-2 items-start">
{/* SIE-export */}
<Card id="sie-export" className="scroll-mt-24">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4 text-muted-foreground" />
{t('export_sie_title')}
</CardTitle>
<CardDescription>{t('export_sie_description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FiscalYearSelector
value={exportPeriodId}
onChange={setExportPeriodId}
includeAllOption={false}
hideFuturePeriods
label={t('export_sie_period_label')}
/>
<Button
onClick={() => {
if (exportPeriodId) {
window.open(`/api/reports/sie-export?period_id=${exportPeriodId}`, '_blank')
}
}}
disabled={!exportPeriodId || isSandbox}
>
<Download className="mr-2 h-4 w-4" />
{t('export_sie_button')}
</Button>
{!exportPeriodId && (
<p className="text-xs text-muted-foreground">{t('export_sie_no_period')}</p>
)}
</CardContent>
</Card>
{/* Molnsynkronisering (Google Drive) */}
{hasCloudBackup && (
<div id="cloud-backup" className="scroll-mt-24">
<CloudBackupCard />
</div>
)}
</div>
</TabsContent>
</Tabs>
</>
)}
+7 -11
View File
@@ -107,18 +107,14 @@ export default function ReportsPage() {
hideFuturePeriods
onReady={() => setIsLoadingInit(false)}
/>
{selectedPeriod && (
<Button
variant="outline"
onClick={() => {
window.open(`/api/reports/sie-export?period_id=${selectedPeriod}`, '_blank')
}}
>
<Download className="h-4 w-4 mr-2" />
{t('download_sie')}
</Button>
)}
</div>
<p className="text-sm text-muted-foreground">
{t('sie_moved_hint')}{' '}
<Link href="/import?view=export#sie-export" className="text-foreground underline underline-offset-4 decoration-muted-foreground/40 hover:decoration-foreground">
{t('sie_moved_link')}
</Link>
.
</p>
{isLoadingInit ? (
<div className="space-y-6">
+7 -20
View File
@@ -1,22 +1,9 @@
import { getTranslations } from 'next-intl/server'
import { BackupDownloadForm } from '@/components/settings/BackupDownloadForm'
import { getBranding } from '@/lib/branding/service'
import { redirect } from 'next/navigation'
export default async function BackupSettingsPage() {
const t = await getTranslations('settings_backup')
const { appName } = getBranding()
return (
<div className="space-y-8">
<section className="space-y-2">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('heading')}
</h2>
<p className="text-sm text-muted-foreground max-w-prose">
{t('intro', { appName: appName.toLowerCase() })}
</p>
</section>
<BackupDownloadForm />
</div>
)
// Säkerhetskopia + Google Drive-molnsynkronisering ligger numera under
// /import (Importera/Exportera). Den här sidan finns kvar endast som en
// permanent omdirigering så att gamla bokmärken och cloud-backup-extensionens
// `settingsPanel.path` fortfarande tar användaren till rätt plats.
export default function BackupSettingsPage() {
redirect('/import?view=export#cloud-backup')
}
+19 -10
View File
@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl'
import { BankDetailsForm, validateBankFields } from '@/components/settings/BankDetailsForm'
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
import { InvoicePreviewCard } from '@/components/settings/InvoicePreviewCard'
import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
@@ -49,17 +50,25 @@ export default function InvoicingSettingsPage() {
}
return (
<div className="space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
<BankDetailsForm settings={settings} />
<div className="border-t border-border/8 pt-8">
<InvoiceSettingsForm settings={settings} />
</div>
</SettingsFormWrapper>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
<BankDetailsForm settings={settings} />
<div className="border-t border-border/8 pt-8">
<InvoiceSettingsForm settings={settings} />
</div>
</SettingsFormWrapper>
{/* PDF settings — saves individually via toggle switches */}
<div className="border-t border-border/8 pt-8">
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
{/* PDF settings — saves individually via toggle switches */}
<div className="border-t border-border/8 pt-8">
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
</div>
</div>
<div className="lg:col-span-1">
<div className="lg:sticky lg:top-8">
<InvoicePreviewCard settings={settings} />
</div>
</div>
</div>
)
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { createServiceClient } from '@/lib/supabase/server'
/**
* GET /api/documents/:id/inline
*
* Same-origin proxy that streams a document attachment with
* `Content-Disposition: inline`, allowing it to render inside
* an <iframe> or <img> tag.
*
* Supabase Storage signed URLs return `Content-Disposition: attachment`,
* which browsers refuse to render inline — that triggers the
* "Det här innehållet har blockerats" error in journal entry previews.
*
* Defense in depth: the user's cookie-bound client authorizes access
* (RLS + explicit company_id filter) before the service-role client
* fetches the file from the non-public `documents` bucket.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { user, supabase, error } = await requireAuth()
if (error) return error
const { id } = await params
// Authorize via the auth-bound client: RLS + explicit company filter
// through user_company_ids (defense in depth).
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, company_id, file_name, mime_type, storage_path')
.eq('id', id)
.single()
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// Explicit membership check on top of RLS.
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('company_id', doc.company_id)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// Use the service-role client to read from the non-public bucket.
const serviceClient = createServiceClient()
const { data: blob, error: downloadError } = await serviceClient.storage
.from('documents')
.download(doc.storage_path)
if (downloadError || !blob) {
return NextResponse.json(
{ error: `Failed to download document: ${downloadError?.message ?? 'unknown error'}` },
{ status: 500 }
)
}
const safeFileName = doc.file_name.replace(/[\r\n"]/g, '_')
return new NextResponse(blob, {
status: 200,
headers: {
'Content-Type': doc.mime_type ?? 'application/octet-stream',
'Content-Disposition': `inline; filename="${safeFileName}"`,
'Cache-Control': 'private, max-age=300',
},
})
}
@@ -0,0 +1,184 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { ExternalLink, FileText, ImageIcon, Paperclip } from 'lucide-react'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
interface DocumentRecord {
id: string
file_name: string
file_size_bytes: number
mime_type: string | null
storage_path: string
created_at: string
download_url?: string
}
interface AttachmentPreviewSheetProps {
entryId: string | null
open: boolean
onOpenChange: (open: boolean) => void
}
function isImageType(type: string | null): boolean {
return type?.startsWith('image/') ?? false
}
function isPdfType(type: string | null): boolean {
return type === 'application/pdf'
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export default function AttachmentPreviewSheet({
entryId,
open,
onOpenChange,
}: AttachmentPreviewSheetProps) {
const t = useTranslations('attachment_preview_sheet')
const [documents, setDocuments] = useState<DocumentRecord[]>([])
const [loading, setLoading] = useState(false)
const fetchAttachments = useCallback(async (id: string) => {
setLoading(true)
try {
const res = await fetch(
`/api/documents?journal_entry_id=${id}&current_only=true`
)
const { data } = await res.json()
const list: DocumentRecord[] = data || []
// Pull a signed download_url for each — used by the
// "open in new tab" link. The iframe/img sources use the
// same-origin inline proxy and do not need download_url.
const enriched = await Promise.all(
list.map(async (doc) => {
try {
const r = await fetch(`/api/documents/${doc.id}`)
const { data: detail } = await r.json()
return detail?.download_url
? { ...doc, download_url: detail.download_url as string }
: doc
} catch {
return doc
}
})
)
setDocuments(enriched)
} catch {
setDocuments([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (open && entryId) {
fetchAttachments(entryId)
} else if (!open) {
// Reset state when closed so the next open starts fresh
setDocuments([])
}
}, [open, entryId, fetchAttachments])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-full overflow-y-auto sm:max-w-[560px]"
>
<SheetHeader>
<SheetTitle>{t('title')}</SheetTitle>
</SheetHeader>
{loading ? (
<div className="space-y-3">
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-[60vh] w-full rounded-lg" />
</div>
) : documents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-3 rounded-full bg-muted p-3">
<Paperclip className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">{t('empty')}</p>
</div>
) : (
<div className="space-y-6">
{documents.map((doc) => {
const inlineSrc = `/api/documents/${doc.id}/inline`
const previewable = isImageType(doc.mime_type) || isPdfType(doc.mime_type)
return (
<div key={doc.id} className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
{isImageType(doc.mime_type) ? (
<ImageIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{doc.file_name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(doc.file_size_bytes)}
</p>
</div>
</div>
{doc.download_url && (
<a
href={doc.download_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('open_in_new_tab')}
</a>
)}
</div>
{isPdfType(doc.mime_type) && (
<iframe
src={inlineSrc}
title={doc.file_name}
className="h-[70vh] w-full rounded-lg border border-border"
/>
)}
{isImageType(doc.mime_type) && (
<div className="overflow-hidden rounded-lg border border-border bg-muted/30">
<img
src={inlineSrc}
alt={doc.file_name}
className="mx-auto max-h-[70vh] w-full object-contain"
/>
</div>
)}
{!previewable && (
<p className="rounded-lg border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
{t('not_previewable')}
</p>
)}
</div>
)
})}
</div>
)}
</SheetContent>
</Sheet>
)
}
@@ -211,7 +211,7 @@ export default function JournalEntryAttachments({
{expandedDoc === doc.id && doc.download_url && isImageType(doc.mime_type) && (
<div className="px-2 py-2">
<img
src={doc.download_url}
src={`/api/documents/${doc.id}/inline`}
alt={doc.file_name}
className="max-h-48 rounded-lg object-contain"
/>
@@ -222,7 +222,7 @@ export default function JournalEntryAttachments({
{expandedDoc === doc.id && doc.download_url && isPdfType(doc.mime_type) && (
<div className="px-2 py-2">
<iframe
src={doc.download_url}
src={`/api/documents/${doc.id}/inline`}
title={doc.file_name}
className="w-full h-[60vh] rounded-lg border"
/>
+69 -8
View File
@@ -18,6 +18,7 @@ import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
import AttachmentPreviewSheet from '@/components/bookkeeping/AttachmentPreviewSheet'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -50,6 +51,7 @@ export default function JournalEntryList({ periodId }: Props) {
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
const [showMissingOnly, setShowMissingOnly] = useState(false)
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
const [previewEntryId, setPreviewEntryId] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'>('date_desc')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
@@ -343,10 +345,36 @@ export default function JournalEntryList({ periodId }: Props) {
)}
<span className="flex-1 truncate">{entry.description}</span>
{attachmentCounts[entry.id] ? (
<span className="flex items-center gap-0.5 text-muted-foreground mr-1" title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}>
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 mr-1 text-muted-foreground transition-colors duration-150 hover:bg-secondary"
>
<span
role="button"
tabIndex={0}
aria-label={t('view_attachments')}
title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setPreviewEntryId(entry.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
setPreviewEntryId(entry.id)
}
}}
>
<span className="flex items-center gap-0.5">
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
</span>
</Button>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span className="mr-1" title={t('missing_attachment_tooltip')}>
@@ -387,10 +415,36 @@ export default function JournalEntryList({ periodId }: Props) {
)}
<span className="ml-auto flex items-center gap-1">
{attachmentCounts[entry.id] ? (
<span className="flex items-center gap-0.5 text-muted-foreground" title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}>
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground transition-colors duration-150 hover:bg-secondary"
>
<span
role="button"
tabIndex={0}
aria-label={t('view_attachments')}
title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setPreviewEntryId(entry.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
setPreviewEntryId(entry.id)
}
}}
>
<span className="flex items-center gap-0.5">
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
</span>
</Button>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span title={t('missing_attachment_tooltip')}>
@@ -525,6 +579,13 @@ export default function JournalEntryList({ periodId }: Props) {
/>
)}
{/* Attachment preview sheet */}
<AttachmentPreviewSheet
entryId={previewEntryId}
open={previewEntryId !== null}
onOpenChange={(open) => { if (!open) setPreviewEntryId(null) }}
/>
{/* Pagination */}
{count > pageSize && (
<div className="flex justify-center gap-2">
+26 -3
View File
@@ -47,6 +47,11 @@ export default function CustomerForm({
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
personal_number: z
.string()
.regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, t('personal_number_invalid'))
.optional()
.or(z.literal('')),
default_payment_terms: z.number().min(1).optional(),
notes: z.string().optional(),
}), [t])
@@ -72,6 +77,7 @@ export default function CustomerForm({
country: initialData?.country || 'Sweden',
org_number: initialData?.org_number || '',
vat_number: initialData?.vat_number || '',
personal_number: initialData?.personal_number || '',
default_payment_terms: initialData?.default_payment_terms || 30,
notes: initialData?.notes || '',
},
@@ -126,6 +132,7 @@ export default function CustomerForm({
onSubmit({
...data,
email: data.email || undefined,
personal_number: data.personal_number || undefined,
})
}
@@ -232,8 +239,24 @@ export default function CustomerForm({
</div>
</div>
{/* Business info */}
{customerType !== 'individual' && (
{/* Identification — depends on customer type */}
{customerType === 'individual' ? (
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">{t('individual_section')}</h3>
<div className="space-y-2">
<Label htmlFor="personal_number">{t('personal_number_label')}</Label>
<Input
id="personal_number"
placeholder={t('personal_number_placeholder')}
{...register('personal_number')}
/>
{errors.personal_number && (
<p className="text-sm text-destructive">{errors.personal_number.message}</p>
)}
</div>
</div>
) : (
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">{t('business_section')}</h3>
@@ -246,7 +269,7 @@ export default function CustomerForm({
/>
</div>
{(customerType === 'eu_business' || customerType === 'swedish_business') && (
{(customerType === 'eu_business' || customerType === 'non_eu_business') && (
<div className="space-y-2">
<Label htmlFor="vat_number">{t('vat_label')}</Label>
<div className="flex gap-2">
+165
View File
@@ -0,0 +1,165 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import type { CompanySettings } from '@/types'
interface InvoicePreviewCardProps {
settings: CompanySettings
}
/**
* Live invoice PDF preview for the invoicing settings page.
*
* Re-fetches the preview PDF whenever the persisted `settings` change
* (debounced 500ms so rapid toggles don't hammer the endpoint). Reads
* the first customer in the company as a dummy recipient — the preview
* endpoint requires a real `customer_id` and `items` payload.
*/
export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
const t = useTranslations('settings_invoicing_preview')
const locale = useLocale() as ErrorLocale
const { company } = useCompany()
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [noCustomers, setNoCustomers] = useState(false)
const currentUrlRef = useRef<string | null>(null)
// Resolve translated sample-line description once per render so the
// effect dependency stays referentially stable across renders.
const sampleItemDescription = t('sample_item_description')
// Debounced refresh whenever `settings` (identity) changes.
useEffect(() => {
if (!company?.id) return
let cancelled = false
const controller = new AbortController()
const timer = setTimeout(async () => {
setIsLoading(true)
setError(null)
setNoCustomers(false)
try {
// Pick any customer for the company — preview endpoint requires one.
const supabase = createClient()
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('id')
.eq('company_id', company.id)
.limit(1)
.maybeSingle()
if (customerError) throw customerError
if (cancelled) return
if (!customer) {
setNoCustomers(true)
setIsLoading(false)
return
}
const response = await fetch('/api/invoices/preview-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
customer_id: customer.id,
currency: 'SEK',
document_type: 'invoice',
items: [
{
description: sampleItemDescription,
quantity: 1,
unit: 'st',
unit_price: 1000,
vat_rate: 25,
},
],
}),
})
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(body?.error || `HTTP ${response.status}`)
}
const blob = await response.blob()
if (cancelled) return
const url = URL.createObjectURL(blob)
// Revoke the previous blob before swapping in the new one so we
// never leak object URLs.
if (currentUrlRef.current) URL.revokeObjectURL(currentUrlRef.current)
currentUrlRef.current = url
setBlobUrl(url)
setIsLoading(false)
} catch (err) {
if (cancelled) return
if (err instanceof Error && err.name === 'AbortError') return
setError(getErrorMessage(err, { locale, context: 'invoice' }))
setIsLoading(false)
}
}, 500)
return () => {
cancelled = true
controller.abort()
clearTimeout(timer)
}
}, [settings, company?.id, sampleItemDescription, locale])
// Final cleanup: revoke the in-flight blob URL when the component unmounts.
useEffect(() => {
return () => {
if (currentUrlRef.current) {
URL.revokeObjectURL(currentUrlRef.current)
currentUrlRef.current = null
}
}
}, [])
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent>
{isLoading && (
<div className="space-y-2" aria-live="polite" aria-busy="true">
<Skeleton className="h-[600px] w-full rounded-lg" />
<p className="text-xs text-muted-foreground">{t('loading')}</p>
</div>
)}
{!isLoading && noCustomers && (
<div className="flex h-[600px] w-full items-center justify-center rounded-lg border border-border bg-muted/30 px-6 text-center">
<p className="text-sm text-muted-foreground">{t('no_customers')}</p>
</div>
)}
{!isLoading && error && (
<div className="flex h-[600px] w-full items-center justify-center rounded-lg border border-border bg-muted/30 px-6 text-center">
<p className="text-sm text-destructive">{t('error')}: {error}</p>
</div>
)}
{!isLoading && !error && !noCustomers && blobUrl && (
<iframe
src={blobUrl}
title={t('iframe_title')}
className="w-full h-[600px] rounded-lg border border-border"
/>
)}
</CardContent>
</Card>
)
}
-1
View File
@@ -35,7 +35,6 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
{ href: '/settings/skatteverket', label: t('skatteverket'), show: hasCompany && !isSandbox && hasSkatteverketExtension },
{ href: '/settings/salary', label: t('salary'), show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: t('templates'), show: hasCompany },
{ href: '/settings/backup', label: t('backup'), show: hasCompany },
{ href: '/settings/account', label: t('account'), show: true },
{ href: '/settings/api', label: t('api'), show: hasCompany && hasMcpExtension },
].filter(item => item.show)
+134
View File
@@ -0,0 +1,134 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = DialogPrimitive.Root
const SheetTrigger = DialogPrimitive.Trigger
const SheetClose = DialogPrimitive.Close
const SheetPortal = DialogPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName
type SheetSide = "top" | "right" | "bottom" | "left"
const sideClasses: Record<SheetSide, string> = {
top:
"inset-x-0 top-0 border-b data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
right:
"inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right",
bottom:
"inset-x-0 bottom-0 border-t data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
left:
"inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left",
}
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
side?: SheetSide
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ className, children, side = "right", ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background p-6 transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:duration-300",
sideClasses[side],
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = DialogPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col space-y-1.5 text-left", className)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:space-x-2 sm:gap-0",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-base font-semibold tracking-tight", className)}
{...props}
/>
))
SheetTitle.displayName = DialogPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = DialogPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
-2
View File
@@ -1,7 +1,5 @@
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/ext/enable-banking/sync/cron
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
0 8 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/reminders/cron
30 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/recurring/cron
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
-2
View File
@@ -1,6 +1,4 @@
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
0 8 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/reminders/cron
30 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/recurring/cron
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
+4
View File
@@ -50,6 +50,10 @@ export const cloudBackupExtension: Extension = {
version: '1.0.0',
sector: 'general',
// The canonical entry point for cloud-backup is now `/import#cloud-backup`
// (under "Importera/Exportera"). `/settings/backup` is preserved as a
// permanent redirect to that anchor so legacy bookmarks and OAuth callbacks
// keep working — see `app/(dashboard)/settings/backup/page.tsx`.
settingsPanel: {
label: 'Molnsynkronisering',
path: '/settings/backup',
+5
View File
@@ -261,6 +261,11 @@ export const CreateCustomerSchema = z.object({
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
personal_number: z
.string()
.regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, 'Invalid personal number')
.optional()
.nullable(),
default_payment_terms: z.number().int().positive().optional(),
notes: z.string().optional(),
})
@@ -135,6 +135,11 @@ describe('generateFullArchive', () => {
file_name: 'receipt.pdf',
storage_path: 'documents/user-1/receipt.pdf',
journal_entry_id: 'entry-1',
journal_entries: {
voucher_number: 17,
voucher_series: 'A',
entry_date: '2024-03-15',
},
},
],
},
@@ -162,6 +167,10 @@ describe('generateFullArchive', () => {
expect(manifest[0].status).toBe('error')
expect(manifest[0].error).toBe('File not found')
expect(manifest[0].fiscal_period_id).toBe(PERIOD_2024.id)
// New manifest fields populated even on error (path is computed before download)
expect(manifest[0].voucher_number).toBe('A17')
expect(manifest[0].entry_date).toBe('2024-03-15')
expect(manifest[0].zip_path).toBe('dokument/2024/A17_receipt.pdf')
})
it('skips documents when include_documents is false', async () => {
@@ -264,8 +273,20 @@ describe('generateFullArchive', () => {
{ data: [PERIOD_2023, PERIOD_2024] },
{
data: [
{ id: 'doc-2023', file_name: 'r23.pdf', storage_path: 'p/r23.pdf', journal_entry_id: 'e-2023' },
{ id: 'doc-2024', file_name: 'r24.pdf', storage_path: 'p/r24.pdf', journal_entry_id: 'e-2024' },
{
id: 'doc-2023',
file_name: 'r23.pdf',
storage_path: 'p/r23.pdf',
journal_entry_id: 'e-2023',
journal_entries: { voucher_number: 7, voucher_series: 'A', entry_date: '2023-06-01' },
},
{
id: 'doc-2024',
file_name: 'r24.pdf',
storage_path: 'p/r24.pdf',
journal_entry_id: 'e-2024',
journal_entries: { voucher_number: 12, voucher_series: 'B', entry_date: '2024-08-20' },
},
],
},
{
@@ -287,13 +308,94 @@ describe('generateFullArchive', () => {
const manifest = JSON.parse(await manifestFile!.async('text'))
expect(manifest).toHaveLength(2)
const byId = Object.fromEntries(
(manifest as Array<{ document_id: string; fiscal_period_id: string | null }>).map((m) => [
m.document_id,
m.fiscal_period_id,
])
(
manifest as Array<{
document_id: string
fiscal_period_id: string | null
voucher_number: string | null
zip_path: string | null
}>
).map((m) => [m.document_id, m])
)
expect(byId['doc-2023']).toBe(PERIOD_2023.id)
expect(byId['doc-2024']).toBe(PERIOD_2024.id)
expect(byId['doc-2023'].fiscal_period_id).toBe(PERIOD_2023.id)
expect(byId['doc-2024'].fiscal_period_id).toBe(PERIOD_2024.id)
expect(byId['doc-2023'].voucher_number).toBe('A7')
expect(byId['doc-2024'].voucher_number).toBe('B12')
expect(byId['doc-2023'].zip_path).toBe('dokument/2023/A7_r23.pdf')
expect(byId['doc-2024'].zip_path).toBe('dokument/2024/B12_r24.pdf')
// Files actually written under the new path
expect(zip.file('dokument/2023/A7_r23.pdf')).not.toBeNull()
expect(zip.file('dokument/2024/B12_r24.pdf')).not.toBeNull()
})
it('routes draft entries and orphans to dokument/_okopplade and disambiguates collisions', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2024] },
{
data: [
// Draft entry — journal_entry_id present but voucher_number is null
{
id: 'doc-draft',
file_name: 'invoice.pdf',
storage_path: 'p/invoice.pdf',
journal_entry_id: 'e-draft',
journal_entries: { voucher_number: null, voucher_series: 'A', entry_date: null },
},
// Two posted docs that collide on the same voucher+filename
{
id: 'doc-collide-1234abcd-ee',
file_name: 'kvitto.pdf',
storage_path: 'p/kvitto-1.pdf',
journal_entry_id: 'e-posted',
journal_entries: { voucher_number: 5, voucher_series: 'A', entry_date: '2024-05-01' },
},
{
id: 'doc-collide-5678efgh-ff',
file_name: 'kvitto.pdf',
storage_path: 'p/kvitto-2.pdf',
journal_entry_id: 'e-posted',
journal_entries: { voucher_number: 5, voucher_series: 'A', entry_date: '2024-05-01' },
},
],
},
// entryIdToPeriodId map — draft and posted both resolve to PERIOD_2024
{
data: [
{ id: 'e-draft', fiscal_period_id: PERIOD_2024.id },
{ id: 'e-posted', fiscal_period_id: PERIOD_2024.id },
],
},
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
const manifest = JSON.parse(await zip.file('dokument/manifest.json')!.async('text')) as Array<{
document_id: string
voucher_number: string | null
zip_path: string | null
}>
const byId = Object.fromEntries(manifest.map((m) => [m.document_id, m]))
// Draft -> _okopplade, no voucher prefix
expect(byId['doc-draft'].voucher_number).toBeNull()
expect(byId['doc-draft'].zip_path).toBe('dokument/_okopplade/invoice.pdf')
// First posted doc gets the canonical path
expect(byId['doc-collide-1234abcd-ee'].zip_path).toBe('dokument/2024/A5_kvitto.pdf')
// Second posted doc gets the id-suffix disambiguation before the extension
expect(byId['doc-collide-5678efgh-ff'].zip_path).toBe(
'dokument/2024/A5_kvitto_doc-coll.pdf'
)
// Both files exist in the ZIP
expect(zip.file('dokument/_okopplade/invoice.pdf')).not.toBeNull()
expect(zip.file('dokument/2024/A5_kvitto.pdf')).not.toBeNull()
expect(zip.file('dokument/2024/A5_kvitto_doc-coll.pdf')).not.toBeNull()
})
it('throws when no fiscal periods exist', async () => {
+98 -4
View File
@@ -30,6 +30,10 @@ interface DocumentManifestEntry {
upload_source: string | null
mime_type: string | null
file_size_bytes: number | null
// New fields (added to make ZIP entries sortable by verifikatnummer)
voucher_number: string | null
entry_date: string | null
zip_path: string | null
status: 'downloaded' | 'missing' | 'error'
error?: string
}
@@ -58,6 +62,14 @@ interface DocumentRow {
upload_source: string | null
mime_type: string | null
file_size_bytes: number | null
// Joined from journal_entries via journal_entry_id. May be null when the
// entry is a draft (no voucher_number yet) or when the doc is orphaned.
// PostgREST returns a single row as an object, not an array, when the FK
// is many-to-one — but we tolerate both shapes defensively.
journal_entries?:
| { voucher_number: number | null; voucher_series: string | null; entry_date: string | null }
| { voucher_number: number | null; voucher_series: string | null; entry_date: string | null }[]
| null
}
interface PeriodReports {
@@ -324,7 +336,7 @@ async function writeDocuments(
supabase
.from('document_attachments')
.select(
'id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes'
'id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes, journal_entries:journal_entry_id(voucher_number, voucher_series, entry_date)'
)
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
@@ -339,12 +351,20 @@ async function writeDocuments(
? documents.filter((d) => d.journal_entry_id && entryIdToPeriodId.has(d.journal_entry_id))
: documents.filter((d) => d.journal_entry_id) // all-mode: keep every linked doc
// Track used paths so we can disambiguate collisions (two documents with
// identical voucher prefix + filename) by appending a short id suffix.
const usedPaths = new Set<string>()
for (const doc of inScopeDocuments) {
const fiscalPeriodId = doc.journal_entry_id
? entryIdToPeriodId.get(doc.journal_entry_id) ?? null
: null
const baseManifest = {
const entryInfo = extractJoinedEntry(doc.journal_entries)
const voucherLabel = formatVoucherLabel(entryInfo)
const zipPath = buildDocumentZipPath(doc, voucherLabel, entryInfo?.entry_date ?? null, usedPaths)
const baseManifest: Omit<DocumentManifestEntry, 'status'> = {
document_id: doc.id,
file_name: doc.file_name,
storage_path: doc.storage_path,
@@ -356,6 +376,9 @@ async function writeDocuments(
upload_source: doc.upload_source,
mime_type: doc.mime_type,
file_size_bytes: doc.file_size_bytes,
voucher_number: voucherLabel,
entry_date: entryInfo?.entry_date ?? null,
zip_path: zipPath,
}
try {
@@ -373,8 +396,10 @@ async function writeDocuments(
}
const buffer = await fileData.arrayBuffer()
const zipFileName = `${doc.id}_${doc.file_name}`
dokument.file(zipFileName, buffer)
// zipPath is fully qualified (`dokument/<year>/<voucher>_<file>` etc.),
// so write at the archive root — calling `dokument.file(zipPath)`
// would double-prefix to `dokument/dokument/...`.
zip.file(zipPath, buffer)
manifest.push({ ...baseManifest, status: 'downloaded' })
} catch (err) {
manifest.push({
@@ -392,6 +417,75 @@ async function writeDocuments(
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
}
/**
* PostgREST returns a many-to-one embedded resource as either an object or an
* array depending on schema introspection (FK is unique vs not). Normalize.
*/
function extractJoinedEntry(
raw: DocumentRow['journal_entries']
): { voucher_number: number | null; voucher_series: string | null; entry_date: string | null } | null {
if (!raw) return null
if (Array.isArray(raw)) return raw[0] ?? null
return raw
}
/**
* Format the voucher label as `<series><number>` (e.g. `A23`, `B12`). Returns
* null if the entry is a draft (no voucher_number assigned yet), in which case
* the doc is treated as orphaned in the ZIP layout.
*/
function formatVoucherLabel(
entry: { voucher_number: number | null; voucher_series: string | null } | null
): string | null {
if (!entry || entry.voucher_number == null) return null
const series = entry.voucher_series ?? ''
return `${series}${entry.voucher_number}`
}
/**
* Build the in-ZIP path for a document.
*
* - Linked to a posted entry with a date: `dokument/<year>/<voucher>_<file>`
* - Linked to a posted entry without a date (defensive): `dokument/_okant-ar/<voucher>_<file>`
* - Orphan (no entry) or draft (no voucher_number): `dokument/_okopplade/<file>`
*
* Collisions are resolved by appending `_<short-id>` before the file extension.
*/
function buildDocumentZipPath(
doc: { id: string; file_name: string },
voucherLabel: string | null,
entryDate: string | null,
usedPaths: Set<string>
): string {
const safeName = sanitizeFileName(doc.file_name || `${doc.id}.bin`)
let folder: string
let prefix: string
if (voucherLabel) {
const year = entryDate ? new Date(entryDate).getUTCFullYear() : NaN
folder = Number.isFinite(year) ? `dokument/${year}` : 'dokument/_okant-ar'
prefix = `${voucherLabel}_`
} else {
folder = 'dokument/_okopplade'
prefix = ''
}
const candidate = `${folder}/${prefix}${safeName}`
if (!usedPaths.has(candidate)) {
usedPaths.add(candidate)
return candidate
}
// Collision — disambiguate with a short id suffix before the extension.
const dotIdx = safeName.lastIndexOf('.')
const stem = dotIdx > 0 ? safeName.slice(0, dotIdx) : safeName
const ext = dotIdx > 0 ? safeName.slice(dotIdx) : ''
const suffix = doc.id.slice(0, 8)
const disambiguated = `${folder}/${prefix}${stem}_${suffix}${ext}`
usedPaths.add(disambiguated)
return disambiguated
}
interface SieImportRow {
id: string
filename: string | null
+40 -5
View File
@@ -72,7 +72,7 @@
"bookkeeping": "Bookkeeping",
"assets": "Fixed assets",
"reports": "Reports",
"import": "Import",
"import": "Import/Export",
"salary": "Payroll",
"employees": "Employees",
"expenses": "Expenses",
@@ -156,7 +156,6 @@
"skatteverket": "Skatteverket",
"salary": "Payroll",
"templates": "Templates",
"backup": "Backup",
"account": "Account",
"api": "API"
},
@@ -506,6 +505,10 @@
"country_label": "Country",
"country_placeholder": "Sweden",
"business_section": "Business details",
"individual_section": "Personal details",
"personal_number_label": "Personal number",
"personal_number_placeholder": "YYYYMMDD-XXXX",
"personal_number_invalid": "Invalid personal number",
"org_number_label": "Org. number",
"org_number_placeholder": "XXXXXX-XXXX",
"vat_label": "VAT number",
@@ -940,6 +943,14 @@
"settings_invoicing": {
"bank_validation_title": "Check bank details"
},
"settings_invoicing_preview": {
"title": "Preview",
"loading": "Generating preview...",
"error": "Could not load preview",
"no_customers": "Add a customer to see a preview of your invoice.",
"sample_item_description": "Sample line",
"iframe_title": "Invoice PDF preview"
},
"settings_bookkeeping": {
"method_heading": "Accounting method",
"method_label": "Method",
@@ -2552,7 +2563,14 @@
"toast_posted_title": "Journal entry posted",
"toast_posted_description": "Journal entry {voucher} has been posted.",
"toast_post_failed": "Could not post",
"toast_post_failed_generic": "Could not post journal entry"
"toast_post_failed_generic": "Could not post journal entry",
"view_attachments": "View attachments"
},
"attachment_preview_sheet": {
"title": "Attachments",
"empty": "No attachments",
"open_in_new_tab": "Open in new tab",
"not_previewable": "Preview is not available for this file type."
},
"journal_attachments": {
"loading": "Loading documents...",
@@ -2999,7 +3017,13 @@
"type_swedish_business": "Swedish company or organization",
"type_eu_business": "EU business",
"type_non_eu_business": "Outside EU",
"verified": "Verified"
"verified": "Verified",
"col_name": "Name",
"col_type": "Type",
"col_identifier": "Org. no. / Personal no.",
"col_email": "Email",
"col_city": "City",
"col_created": "Created"
},
"invoices": {
"title": "Invoices",
@@ -3073,6 +3097,8 @@
"reports": {
"title": "Reports",
"download_sie": "Download SIE file",
"sie_moved_hint": "SIE export now lives under Import/Export.",
"sie_moved_link": "Open SIE export",
"download_pdf": "Download PDF",
"categories_aria": "Report categories",
"group_interim": "Interim",
@@ -3120,6 +3146,8 @@
"import": {
"title": "Import",
"subtitle": "Import bank transactions or bookkeeping data into your company",
"tab_import": "Import",
"tab_export": "Export",
"sandbox_disabled": "Import is not available in the sandbox. Create an account to import data.",
"back_to_choices": "Back to choices",
"psd2_title": "Connect bank",
@@ -3136,7 +3164,14 @@
"csv_chip_suppliers": "Suppliers",
"sie_title": "Bookkeeping data (SIE)",
"sie_description": "Import journal entries and chart of accounts from another bookkeeping system.",
"loading_migration": "Loading migration tool..."
"loading_migration": "Loading migration tool...",
"export_heading": "Export",
"export_sie_title": "Export SIE file",
"export_sie_description": "Export your bookkeeping as a SIE4 file for your accountant or for migrating to another system.",
"export_sie_period_label": "Fiscal year",
"export_sie_button": "Download SIE",
"export_sie_no_period": "Select a fiscal year to export.",
"export_cloud_title": "Cloud backup to Google Drive"
},
"empty": {
"invoices_title": "No invoices yet",
+40 -5
View File
@@ -72,7 +72,7 @@
"bookkeeping": "Bokföring",
"assets": "Anläggningstillgångar",
"reports": "Rapporter",
"import": "Importera",
"import": "Importera/Exportera",
"salary": "Löner",
"employees": "Anställda",
"expenses": "Utlägg",
@@ -156,7 +156,6 @@
"skatteverket": "Skatteverket",
"salary": "Löner",
"templates": "Mallar",
"backup": "Säkerhetsbackup",
"account": "Konto",
"api": "API"
},
@@ -506,6 +505,10 @@
"country_label": "Land",
"country_placeholder": "Sweden",
"business_section": "Företagsuppgifter",
"individual_section": "Personuppgifter",
"personal_number_label": "Personnummer",
"personal_number_placeholder": "YYYYMMDD-XXXX",
"personal_number_invalid": "Ogiltigt personnummer",
"org_number_label": "Organisationsnummer",
"org_number_placeholder": "XXXXXX-XXXX",
"vat_label": "VAT-nummer (momsreg.nr)",
@@ -940,6 +943,14 @@
"settings_invoicing": {
"bank_validation_title": "Kontrollera bankuppgifter"
},
"settings_invoicing_preview": {
"title": "Förhandsvisning",
"loading": "Genererar förhandsvisning...",
"error": "Kunde inte ladda förhandsvisning",
"no_customers": "Lägg till en kund för att se en förhandsvisning av din faktura.",
"sample_item_description": "Exempelrad",
"iframe_title": "PDF-förhandsvisning av faktura"
},
"settings_bookkeeping": {
"method_heading": "Bokföringsmetod",
"method_label": "Metod",
@@ -2552,7 +2563,14 @@
"toast_posted_title": "Verifikat bokfört",
"toast_posted_description": "Verifikat {voucher} har bokförts.",
"toast_post_failed": "Kunde inte bokföra",
"toast_post_failed_generic": "Kunde inte bokföra verifikat"
"toast_post_failed_generic": "Kunde inte bokföra verifikat",
"view_attachments": "Visa bilagor"
},
"attachment_preview_sheet": {
"title": "Bilagor",
"empty": "Inga bilagor",
"open_in_new_tab": "Öppna i nytt fönster",
"not_previewable": "Förhandsvisning är inte tillgänglig för denna filtyp."
},
"journal_attachments": {
"loading": "Laddar underlag...",
@@ -2999,7 +3017,13 @@
"type_swedish_business": "Svenskt företag eller organisation",
"type_eu_business": "EU-företag",
"type_non_eu_business": "Utanför EU",
"verified": "Verifierad"
"verified": "Verifierad",
"col_name": "Namn",
"col_type": "Typ",
"col_identifier": "Org.nr / Personnummer",
"col_email": "E-post",
"col_city": "Stad",
"col_created": "Skapad"
},
"invoices": {
"title": "Fakturor",
@@ -3073,6 +3097,8 @@
"reports": {
"title": "Rapporter",
"download_sie": "Ladda ner SIE-fil",
"sie_moved_hint": "SIE-export finns nu under Importera/Exportera.",
"sie_moved_link": "Öppna SIE-export",
"download_pdf": "Ladda ner PDF",
"categories_aria": "Rapportkategorier",
"group_interim": "Löpande",
@@ -3120,6 +3146,8 @@
"import": {
"title": "Importera",
"subtitle": "Importera banktransaktioner eller bokföringsdata till ditt företag",
"tab_import": "Importera",
"tab_export": "Exportera",
"sandbox_disabled": "Import är inte tillgängligt i sandlådemiljön. Skapa ett konto för att importera data.",
"back_to_choices": "Tillbaka till val",
"psd2_title": "Koppla bank",
@@ -3136,7 +3164,14 @@
"csv_chip_suppliers": "Leverantörer",
"sie_title": "Bokföringsdata (SIE)",
"sie_description": "Importera verifikationer och kontoplan från ett annat bokföringsprogram.",
"loading_migration": "Laddar migreringsverktyg..."
"loading_migration": "Laddar migreringsverktyg...",
"export_heading": "Exportera",
"export_sie_title": "Exportera SIE-fil",
"export_sie_description": "Exportera bokföringen som SIE4-fil för revisor eller programmigrering.",
"export_sie_period_label": "Räkenskapsår",
"export_sie_button": "Ladda ner SIE",
"export_sie_no_period": "Välj ett räkenskapsår för att exportera.",
"export_cloud_title": "Säkerhetskopia till Google Drive"
},
"empty": {
"invoices_title": "Inga fakturor ännu",
@@ -0,0 +1,34 @@
-- Add personnummer to customers.
--
-- For customer_type = 'individual' we need to capture the personal number
-- (personnummer) for ROT/RUT invoicing and identification on receipts.
-- Stored as the same TEXT column shape as org_number — application code
-- continues to use customer_type to decide which field to surface.
--
-- Accepts the canonical Swedish personnummer formats:
-- YYMMDD-XXXX (10 digits, century-less)
-- YYMMDD+XXXX (10 digits, '+' separator for individuals 100+ years old)
-- YYYYMMDD-XXXX (12 digits, full year)
-- YYYYMMDD+XXXX (12 digits, '+' separator)
-- With separator omitted: YYMMDDXXXX or YYYYMMDDXXXX
--
-- NULL is allowed (only required when applicable / user-provided).
ALTER TABLE public.customers
ADD COLUMN IF NOT EXISTS personal_number TEXT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conrelid = 'public.customers'::regclass
AND conname = 'customers_personal_number_check'
) THEN
ALTER TABLE public.customers
ADD CONSTRAINT customers_personal_number_check
CHECK (personal_number IS NULL OR personal_number ~ '^(\d{6}|\d{8})[-+]?\d{4}$');
END IF;
END $$;
NOTIFY pgrst, 'reload schema';
+1
View File
@@ -405,6 +405,7 @@ export function makeCustomer(overrides: Partial<Customer> = {}): Customer {
vat_number: 'SE556677889901',
vat_number_validated: true,
vat_number_validated_at: '2024-01-01T00:00:00Z',
personal_number: null,
default_payment_terms: 30,
notes: null,
created_at: '2024-01-01T00:00:00Z',
+2
View File
@@ -477,6 +477,7 @@ export interface Customer {
vat_number: string | null
vat_number_validated: boolean
vat_number_validated_at: string | null
personal_number: string | null
// Payment
default_payment_terms: number // Days
@@ -828,6 +829,7 @@ export interface CreateCustomerInput {
country?: string
org_number?: string
vat_number?: string
personal_number?: string
default_payment_terms?: number
notes?: string
}
+1 -9
View File
@@ -4,15 +4,7 @@
"path": "/api/deadlines/status/cron",
"schedule": "0 6 * * *"
},
{
"path": "/api/invoices/reminders/cron",
"schedule": "0 8 * * *"
},
{
"path": "/api/invoices/recurring/cron",
"schedule": "30 6 * * *"
},
{
{
"path": "/api/tax-deadlines/cron",
"schedule": "0 0 2 1 *"
},