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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}¤t_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"
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 *"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user