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
@@ -0,0 +1,184 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { ExternalLink, FileText, ImageIcon, Paperclip } from 'lucide-react'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
interface DocumentRecord {
id: string
file_name: string
file_size_bytes: number
mime_type: string | null
storage_path: string
created_at: string
download_url?: string
}
interface AttachmentPreviewSheetProps {
entryId: string | null
open: boolean
onOpenChange: (open: boolean) => void
}
function isImageType(type: string | null): boolean {
return type?.startsWith('image/') ?? false
}
function isPdfType(type: string | null): boolean {
return type === 'application/pdf'
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export default function AttachmentPreviewSheet({
entryId,
open,
onOpenChange,
}: AttachmentPreviewSheetProps) {
const t = useTranslations('attachment_preview_sheet')
const [documents, setDocuments] = useState<DocumentRecord[]>([])
const [loading, setLoading] = useState(false)
const fetchAttachments = useCallback(async (id: string) => {
setLoading(true)
try {
const res = await fetch(
`/api/documents?journal_entry_id=${id}&current_only=true`
)
const { data } = await res.json()
const list: DocumentRecord[] = data || []
// Pull a signed download_url for each — used by the
// "open in new tab" link. The iframe/img sources use the
// same-origin inline proxy and do not need download_url.
const enriched = await Promise.all(
list.map(async (doc) => {
try {
const r = await fetch(`/api/documents/${doc.id}`)
const { data: detail } = await r.json()
return detail?.download_url
? { ...doc, download_url: detail.download_url as string }
: doc
} catch {
return doc
}
})
)
setDocuments(enriched)
} catch {
setDocuments([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (open && entryId) {
fetchAttachments(entryId)
} else if (!open) {
// Reset state when closed so the next open starts fresh
setDocuments([])
}
}, [open, entryId, fetchAttachments])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-full overflow-y-auto sm:max-w-[560px]"
>
<SheetHeader>
<SheetTitle>{t('title')}</SheetTitle>
</SheetHeader>
{loading ? (
<div className="space-y-3">
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-[60vh] w-full rounded-lg" />
</div>
) : documents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-3 rounded-full bg-muted p-3">
<Paperclip className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">{t('empty')}</p>
</div>
) : (
<div className="space-y-6">
{documents.map((doc) => {
const inlineSrc = `/api/documents/${doc.id}/inline`
const previewable = isImageType(doc.mime_type) || isPdfType(doc.mime_type)
return (
<div key={doc.id} className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
{isImageType(doc.mime_type) ? (
<ImageIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{doc.file_name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(doc.file_size_bytes)}
</p>
</div>
</div>
{doc.download_url && (
<a
href={doc.download_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('open_in_new_tab')}
</a>
)}
</div>
{isPdfType(doc.mime_type) && (
<iframe
src={inlineSrc}
title={doc.file_name}
className="h-[70vh] w-full rounded-lg border border-border"
/>
)}
{isImageType(doc.mime_type) && (
<div className="overflow-hidden rounded-lg border border-border bg-muted/30">
<img
src={inlineSrc}
alt={doc.file_name}
className="mx-auto max-h-[70vh] w-full object-contain"
/>
</div>
)}
{!previewable && (
<p className="rounded-lg border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
{t('not_previewable')}
</p>
)}
</div>
)
})}
</div>
)}
</SheetContent>
</Sheet>
)
}
@@ -211,7 +211,7 @@ export default function JournalEntryAttachments({
{expandedDoc === doc.id && doc.download_url && isImageType(doc.mime_type) && (
<div className="px-2 py-2">
<img
src={doc.download_url}
src={`/api/documents/${doc.id}/inline`}
alt={doc.file_name}
className="max-h-48 rounded-lg object-contain"
/>
@@ -222,7 +222,7 @@ export default function JournalEntryAttachments({
{expandedDoc === doc.id && doc.download_url && isPdfType(doc.mime_type) && (
<div className="px-2 py-2">
<iframe
src={doc.download_url}
src={`/api/documents/${doc.id}/inline`}
title={doc.file_name}
className="w-full h-[60vh] rounded-lg border"
/>
+69 -8
View File
@@ -18,6 +18,7 @@ import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
import AttachmentPreviewSheet from '@/components/bookkeeping/AttachmentPreviewSheet'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -50,6 +51,7 @@ export default function JournalEntryList({ periodId }: Props) {
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
const [showMissingOnly, setShowMissingOnly] = useState(false)
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
const [previewEntryId, setPreviewEntryId] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'>('date_desc')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
@@ -343,10 +345,36 @@ export default function JournalEntryList({ periodId }: Props) {
)}
<span className="flex-1 truncate">{entry.description}</span>
{attachmentCounts[entry.id] ? (
<span className="flex items-center gap-0.5 text-muted-foreground mr-1" title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}>
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 mr-1 text-muted-foreground transition-colors duration-150 hover:bg-secondary"
>
<span
role="button"
tabIndex={0}
aria-label={t('view_attachments')}
title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setPreviewEntryId(entry.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
setPreviewEntryId(entry.id)
}
}}
>
<span className="flex items-center gap-0.5">
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
</span>
</Button>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span className="mr-1" title={t('missing_attachment_tooltip')}>
@@ -387,10 +415,36 @@ export default function JournalEntryList({ periodId }: Props) {
)}
<span className="ml-auto flex items-center gap-1">
{attachmentCounts[entry.id] ? (
<span className="flex items-center gap-0.5 text-muted-foreground" title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}>
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground transition-colors duration-150 hover:bg-secondary"
>
<span
role="button"
tabIndex={0}
aria-label={t('view_attachments')}
title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setPreviewEntryId(entry.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
setPreviewEntryId(entry.id)
}
}}
>
<span className="flex items-center gap-0.5">
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
</span>
</Button>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span title={t('missing_attachment_tooltip')}>
@@ -525,6 +579,13 @@ export default function JournalEntryList({ periodId }: Props) {
/>
)}
{/* Attachment preview sheet */}
<AttachmentPreviewSheet
entryId={previewEntryId}
open={previewEntryId !== null}
onOpenChange={(open) => { if (!open) setPreviewEntryId(null) }}
/>
{/* Pagination */}
{count > pageSize && (
<div className="flex justify-center gap-2">
+26 -3
View File
@@ -47,6 +47,11 @@ export default function CustomerForm({
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
personal_number: z
.string()
.regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, t('personal_number_invalid'))
.optional()
.or(z.literal('')),
default_payment_terms: z.number().min(1).optional(),
notes: z.string().optional(),
}), [t])
@@ -72,6 +77,7 @@ export default function CustomerForm({
country: initialData?.country || 'Sweden',
org_number: initialData?.org_number || '',
vat_number: initialData?.vat_number || '',
personal_number: initialData?.personal_number || '',
default_payment_terms: initialData?.default_payment_terms || 30,
notes: initialData?.notes || '',
},
@@ -126,6 +132,7 @@ export default function CustomerForm({
onSubmit({
...data,
email: data.email || undefined,
personal_number: data.personal_number || undefined,
})
}
@@ -232,8 +239,24 @@ export default function CustomerForm({
</div>
</div>
{/* Business info */}
{customerType !== 'individual' && (
{/* Identification — depends on customer type */}
{customerType === 'individual' ? (
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">{t('individual_section')}</h3>
<div className="space-y-2">
<Label htmlFor="personal_number">{t('personal_number_label')}</Label>
<Input
id="personal_number"
placeholder={t('personal_number_placeholder')}
{...register('personal_number')}
/>
{errors.personal_number && (
<p className="text-sm text-destructive">{errors.personal_number.message}</p>
)}
</div>
</div>
) : (
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">{t('business_section')}</h3>
@@ -246,7 +269,7 @@ export default function CustomerForm({
/>
</div>
{(customerType === 'eu_business' || customerType === 'swedish_business') && (
{(customerType === 'eu_business' || customerType === 'non_eu_business') && (
<div className="space-y-2">
<Label htmlFor="vat_number">{t('vat_label')}</Label>
<div className="flex gap-2">
+165
View File
@@ -0,0 +1,165 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import type { CompanySettings } from '@/types'
interface InvoicePreviewCardProps {
settings: CompanySettings
}
/**
* Live invoice PDF preview for the invoicing settings page.
*
* Re-fetches the preview PDF whenever the persisted `settings` change
* (debounced 500ms so rapid toggles don't hammer the endpoint). Reads
* the first customer in the company as a dummy recipient — the preview
* endpoint requires a real `customer_id` and `items` payload.
*/
export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
const t = useTranslations('settings_invoicing_preview')
const locale = useLocale() as ErrorLocale
const { company } = useCompany()
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [noCustomers, setNoCustomers] = useState(false)
const currentUrlRef = useRef<string | null>(null)
// Resolve translated sample-line description once per render so the
// effect dependency stays referentially stable across renders.
const sampleItemDescription = t('sample_item_description')
// Debounced refresh whenever `settings` (identity) changes.
useEffect(() => {
if (!company?.id) return
let cancelled = false
const controller = new AbortController()
const timer = setTimeout(async () => {
setIsLoading(true)
setError(null)
setNoCustomers(false)
try {
// Pick any customer for the company — preview endpoint requires one.
const supabase = createClient()
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('id')
.eq('company_id', company.id)
.limit(1)
.maybeSingle()
if (customerError) throw customerError
if (cancelled) return
if (!customer) {
setNoCustomers(true)
setIsLoading(false)
return
}
const response = await fetch('/api/invoices/preview-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
customer_id: customer.id,
currency: 'SEK',
document_type: 'invoice',
items: [
{
description: sampleItemDescription,
quantity: 1,
unit: 'st',
unit_price: 1000,
vat_rate: 25,
},
],
}),
})
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(body?.error || `HTTP ${response.status}`)
}
const blob = await response.blob()
if (cancelled) return
const url = URL.createObjectURL(blob)
// Revoke the previous blob before swapping in the new one so we
// never leak object URLs.
if (currentUrlRef.current) URL.revokeObjectURL(currentUrlRef.current)
currentUrlRef.current = url
setBlobUrl(url)
setIsLoading(false)
} catch (err) {
if (cancelled) return
if (err instanceof Error && err.name === 'AbortError') return
setError(getErrorMessage(err, { locale, context: 'invoice' }))
setIsLoading(false)
}
}, 500)
return () => {
cancelled = true
controller.abort()
clearTimeout(timer)
}
}, [settings, company?.id, sampleItemDescription, locale])
// Final cleanup: revoke the in-flight blob URL when the component unmounts.
useEffect(() => {
return () => {
if (currentUrlRef.current) {
URL.revokeObjectURL(currentUrlRef.current)
currentUrlRef.current = null
}
}
}, [])
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent>
{isLoading && (
<div className="space-y-2" aria-live="polite" aria-busy="true">
<Skeleton className="h-[600px] w-full rounded-lg" />
<p className="text-xs text-muted-foreground">{t('loading')}</p>
</div>
)}
{!isLoading && noCustomers && (
<div className="flex h-[600px] w-full items-center justify-center rounded-lg border border-border bg-muted/30 px-6 text-center">
<p className="text-sm text-muted-foreground">{t('no_customers')}</p>
</div>
)}
{!isLoading && error && (
<div className="flex h-[600px] w-full items-center justify-center rounded-lg border border-border bg-muted/30 px-6 text-center">
<p className="text-sm text-destructive">{t('error')}: {error}</p>
</div>
)}
{!isLoading && !error && !noCustomers && blobUrl && (
<iframe
src={blobUrl}
title={t('iframe_title')}
className="w-full h-[600px] rounded-lg border border-border"
/>
)}
</CardContent>
</Card>
)
}
-1
View File
@@ -35,7 +35,6 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
{ href: '/settings/skatteverket', label: t('skatteverket'), show: hasCompany && !isSandbox && hasSkatteverketExtension },
{ href: '/settings/salary', label: t('salary'), show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: t('templates'), show: hasCompany },
{ href: '/settings/backup', label: t('backup'), show: hasCompany },
{ href: '/settings/account', label: t('account'), show: true },
{ href: '/settings/api', label: t('api'), show: hasCompany && hasMcpExtension },
].filter(item => item.show)
+134
View File
@@ -0,0 +1,134 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = DialogPrimitive.Root
const SheetTrigger = DialogPrimitive.Trigger
const SheetClose = DialogPrimitive.Close
const SheetPortal = DialogPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName
type SheetSide = "top" | "right" | "bottom" | "left"
const sideClasses: Record<SheetSide, string> = {
top:
"inset-x-0 top-0 border-b data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
right:
"inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right",
bottom:
"inset-x-0 bottom-0 border-t data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
left:
"inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left",
}
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
side?: SheetSide
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ className, children, side = "right", ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background p-6 transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:duration-300",
sideClasses[side],
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = DialogPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col space-y-1.5 text-left", className)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:space-x-2 sm:gap-0",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-base font-semibold tracking-tight", className)}
{...props}
/>
))
SheetTitle.displayName = DialogPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = DialogPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}