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',
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user