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