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