UX optimization
This commit is contained in:
@@ -6,11 +6,13 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { Plus, Trash2, AlertTriangle } from 'lucide-react'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType } from '@/types'
|
||||
|
||||
@@ -56,11 +58,17 @@ export default function JournalEntryForm({
|
||||
)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [showNoDocWarning, setShowNoDocWarning] = useState(false)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
|
||||
const isUploading = uploadedFiles.some((f) => f.status === 'uploading')
|
||||
|
||||
const hasContent = description !== '' ||
|
||||
lines.some(l => l.account_number !== '' || l.debit_amount !== '' || l.credit_amount !== '') ||
|
||||
uploadedFiles.length > 0
|
||||
useUnsavedChanges(hasContent)
|
||||
|
||||
useEffect(() => {
|
||||
fetchPeriods()
|
||||
fetchAccounts()
|
||||
@@ -118,6 +126,11 @@ export default function JournalEntryForm({
|
||||
|
||||
const handleReview = () => {
|
||||
if (!selectedPeriod || !description || !isBalanced) return
|
||||
const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded')
|
||||
if (!embedded && !hasDocuments) {
|
||||
setShowNoDocWarning(true)
|
||||
return
|
||||
}
|
||||
setShowReview(true)
|
||||
}
|
||||
|
||||
@@ -152,8 +165,8 @@ export default function JournalEntryForm({
|
||||
|
||||
if (result.error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: result.error,
|
||||
title: 'Kunde inte skapa verifikation',
|
||||
description: getErrorMessage(result, { context: 'journal_entry', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
@@ -161,6 +174,7 @@ export default function JournalEntryForm({
|
||||
const journalEntryId = result.data?.id ?? result.journal_entry_id
|
||||
if (journalEntryId && uploadedFiles.length > 0) {
|
||||
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
|
||||
let linkFailCount = 0
|
||||
for (const file of filesToLink) {
|
||||
try {
|
||||
await fetch(`/api/documents/${file.id}/link`, {
|
||||
@@ -170,8 +184,16 @@ export default function JournalEntryForm({
|
||||
})
|
||||
} catch (linkErr) {
|
||||
console.error('[JournalEntryForm] Failed to link document:', linkErr)
|
||||
linkFailCount++
|
||||
}
|
||||
}
|
||||
if (linkFailCount > 0) {
|
||||
toast({
|
||||
title: 'Underlag kunde inte bifogas',
|
||||
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen. Forsok igen via bokforingssidan.`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
@@ -379,6 +401,31 @@ export default function JournalEntryForm({
|
||||
attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
|
||||
{/* Warning dialog when no documents attached */}
|
||||
<ConfirmationDialog
|
||||
open={showNoDocWarning}
|
||||
onOpenChange={setShowNoDocWarning}
|
||||
onConfirm={() => {
|
||||
setShowNoDocWarning(false)
|
||||
setShowReview(true)
|
||||
}}
|
||||
isSubmitting={false}
|
||||
title="Underlag saknas"
|
||||
warningText="Ingen verifikation har bifogats. Enligt bokforingslagen (BFL) kravs underlag for varje bokforingspost."
|
||||
confirmLabel="Fortsatt anda"
|
||||
>
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-4">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||
<div className="text-sm text-amber-800 dark:text-amber-300">
|
||||
<p className="font-medium mb-1">Inget underlag bifogat</p>
|
||||
<p>
|
||||
Enligt bokforingslagen (BFL 5 kap. 6-7 §§) ska varje bokforingspost ha en verifikation som
|
||||
underlag. Du kan bifoga underlag nu eller fortsatta utan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmationDialog>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
MessageCircle,
|
||||
FileWarning,
|
||||
} from 'lucide-react'
|
||||
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||
|
||||
@@ -47,6 +48,7 @@ interface DashboardContentProps {
|
||||
bankBalance: number | null
|
||||
deadlines: Deadline[]
|
||||
receiptQueue: ReceiptQueueSummary | null
|
||||
missingUnderlagCount: number
|
||||
}
|
||||
onboardingProgress?: OnboardingProgress
|
||||
}
|
||||
@@ -176,6 +178,29 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
)
|
||||
}
|
||||
|
||||
if (summary.missingUnderlagCount > 0) {
|
||||
alertItems.push(
|
||||
<Link key="missing-underlag" href="/bookkeeping?missingUnderlag=true" className="group">
|
||||
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileWarning className="h-4 w-4 text-warning-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Saknade underlag</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.missingUnderlagCount} verifikationer utan underlag
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_ALERTS = 3
|
||||
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
|
||||
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
|
||||
@@ -228,6 +253,9 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
if (summary.receiptQueue && summary.receiptQueue.pending_review_count > 0) {
|
||||
todoItems.push({ label: 'kvitton att granska', href: '/receipts', count: summary.receiptQueue.pending_review_count, variant: 'default' })
|
||||
}
|
||||
if (summary.missingUnderlagCount > 0) {
|
||||
todoItems.push({ label: 'saknade underlag', href: '/bookkeeping?missingUnderlag=true', count: summary.missingUnderlagCount, variant: 'warning' })
|
||||
}
|
||||
|
||||
if (todoItems.length === 0) return null
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { Loader2, ArrowRight, ArrowLeft, Check } from 'lucide-react'
|
||||
import { Loader2, ArrowRight, ArrowLeft, Check, CalendarDays, Info } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { monthsBetween } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import type { MomsPeriod, EntityType } from '@/types'
|
||||
|
||||
const schema = z.object({
|
||||
@@ -58,6 +59,14 @@ const monthNames = [
|
||||
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
|
||||
]
|
||||
|
||||
function formatSwedishDate(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const day = d.getDate()
|
||||
const month = monthNames[d.getMonth()].toLowerCase()
|
||||
const year = d.getFullYear()
|
||||
return `${day} ${month} ${year}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last day of a given month (1-indexed).
|
||||
*/
|
||||
@@ -163,6 +172,7 @@ export default function Step3TaxRegistration({
|
||||
const firstYearStart = watch('first_year_start')
|
||||
const firstYearEnd = watch('first_year_end')
|
||||
const fiscalYearEndMonth = watch('fiscal_year_end_month')
|
||||
const accountingMethod = watch('accounting_method')
|
||||
|
||||
// State for AB first-year end month selector
|
||||
const [abEndMonth, setAbEndMonth] = useState<number>(
|
||||
@@ -424,6 +434,21 @@ export default function Step3TaxRegistration({
|
||||
Ingen giltig slutperiod hittades. Kontrollera startdatumet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{firstYearStart && firstYearEnd && (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CalendarDays className="h-4 w-4 text-primary" />
|
||||
Ditt första räkenskapsår
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatSwedishDate(firstYearStart)} – {formatSwedishDate(firstYearEnd)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{monthsBetween(firstYearStart, firstYearEnd)} månader
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -464,6 +489,21 @@ export default function Step3TaxRegistration({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
De flesta har kalenderår (december). Brutet räkenskapsår slutar annan månad.
|
||||
</p>
|
||||
|
||||
{fiscalYearEndMonth && (
|
||||
<div className="mt-3 rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CalendarDays className="h-4 w-4 text-primary" />
|
||||
Ditt räkenskapsår
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{fiscalYearEndMonth === 12
|
||||
? `1 januari \u2013 31 december (kalenderår)`
|
||||
: `1 ${monthNames[fiscalYearEndMonth].toLowerCase()} \u2013 ${lastDayOfMonth(2025, fiscalYearEndMonth)} ${monthNames[fiscalYearEndMonth - 1].toLowerCase()}`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">12 månader</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -594,11 +634,22 @@ export default function Step3TaxRegistration({
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{entityType === 'aktiebolag'
|
||||
? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden.'
|
||||
: 'Som enskild firma med omsättning under 3 MSEK kan du välja kontantmetoden.'}
|
||||
</p>
|
||||
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
{accountingMethod === 'accrual' ? 'Faktureringsmetoden' : 'Kontantmetoden'}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{accountingMethod === 'accrual'
|
||||
? 'Intäkter och kostnader bokförs när fakturan skickas eller tas emot, oavsett när betalningen sker. Detta ger en mer rättvisande bild av verksamhetens ekonomi.'
|
||||
: 'Intäkter och kostnader bokförs först när betalningen faktiskt sker. Enklare att hantera men ger en mindre exakt bild av verksamhetens ekonomi vid varje given tidpunkt.'}
|
||||
</p>
|
||||
{entityType === 'aktiebolag' && (
|
||||
<p className="text-xs text-amber-700 bg-amber-50 rounded px-2 py-1">
|
||||
Aktiebolag med omsättning över 3 MSEK per år måste använda faktureringsmetoden.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Calendar, Copy, RefreshCw, Loader2, ExternalLink, Check } from 'lucide-react'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import type { CalendarFeed } from '@/types'
|
||||
|
||||
interface CalendarFeedWithUrls extends CalendarFeed {
|
||||
@@ -24,6 +25,7 @@ export function CalendarFeedSettings() {
|
||||
const [isRegenerating, setIsRegenerating] = useState(false)
|
||||
const [feed, setFeed] = useState<CalendarFeedWithUrls | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeed()
|
||||
@@ -99,9 +101,13 @@ export function CalendarFeedSettings() {
|
||||
}
|
||||
|
||||
const regenerateToken = async () => {
|
||||
if (!confirm('Är du säker? Den gamla länken kommer sluta fungera.')) {
|
||||
return
|
||||
}
|
||||
const ok = await confirmAction({
|
||||
title: 'Skapa ny kalender-länk',
|
||||
description: 'Den gamla länken slutar fungera omedelbart. Du behöver uppdatera länken i alla kalenderappar som använder den.',
|
||||
confirmLabel: 'Skapa ny länk',
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
setIsRegenerating(true)
|
||||
|
||||
@@ -334,6 +340,8 @@ export function CalendarFeedSettings() {
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DestructiveConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight } from 'lucide-react'
|
||||
import { ArrowUpRight, ArrowDownRight, ChevronDown, ChevronUp, Paperclip } from 'lucide-react'
|
||||
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
|
||||
@@ -38,17 +44,58 @@ export default function TransactionBookingDialog({
|
||||
transaction,
|
||||
onBooked,
|
||||
}: TransactionBookingDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [showUploadZone, setShowUploadZone] = useState(false)
|
||||
|
||||
if (!transaction) return null
|
||||
|
||||
const isIncome = transaction.amount > 0
|
||||
|
||||
const handleBooked = async (transactionId: string, journalEntryId: string) => {
|
||||
// Link any uploaded documents to the new journal entry
|
||||
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
|
||||
if (filesToLink.length > 0) {
|
||||
let linkFailCount = 0
|
||||
for (const file of filesToLink) {
|
||||
try {
|
||||
await fetch(`/api/documents/${file.id}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ journal_entry_id: journalEntryId }),
|
||||
})
|
||||
} catch (linkErr) {
|
||||
console.error('[TransactionBookingDialog] Failed to link document:', linkErr)
|
||||
linkFailCount++
|
||||
}
|
||||
}
|
||||
if (linkFailCount > 0) {
|
||||
toast({
|
||||
title: 'Underlag kunde inte bifogas',
|
||||
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen. Forsok igen via bokforingssidan.`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFiles([])
|
||||
setShowUploadZone(false)
|
||||
onBooked(transactionId, journalEntryId)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setUploadedFiles([])
|
||||
setShowUploadZone(false)
|
||||
}
|
||||
onOpenChange(o)
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bokför transaktion</DialogTitle>
|
||||
<DialogTitle>Bokfor transaktion</DialogTitle>
|
||||
<DialogDescription>
|
||||
Skapa en verifikation för transaktionen
|
||||
Skapa en verifikation for transaktionen
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -77,6 +124,39 @@ export default function TransactionBookingDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Document upload section */}
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadZone(!showUploadZone)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Underlag (valfritt)</span>
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showUploadZone ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{showUploadZone && (
|
||||
<div className="px-3 pb-3">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<JournalEntryForm
|
||||
key={transaction.id}
|
||||
embedded
|
||||
@@ -86,7 +166,7 @@ export default function TransactionBookingDialog({
|
||||
submitUrl={`/api/transactions/${transaction.id}/book`}
|
||||
sourceType="bank_transaction"
|
||||
sourceId={transaction.id}
|
||||
onEntryCreated={(entryId) => onBooked(transaction.id, entryId)}
|
||||
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DestructiveConfirmDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'destructive' | 'warning'
|
||||
onConfirm: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function DestructiveConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Bekräfta',
|
||||
cancelLabel = 'Avbryt',
|
||||
variant = 'destructive',
|
||||
onConfirm,
|
||||
}: DestructiveConfirmDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await onConfirm()
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (isLoading) return
|
||||
onOpenChange(v)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full',
|
||||
variant === 'destructive'
|
||||
? 'bg-red-100 text-red-600'
|
||||
: 'bg-amber-100 text-amber-600'
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant === 'destructive' ? 'destructive' : 'default'}
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading}
|
||||
className={
|
||||
variant === 'warning'
|
||||
? 'bg-amber-600 hover:bg-amber-700 text-white'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'destructive' | 'warning'
|
||||
}
|
||||
|
||||
interface UseDestructiveConfirmReturn {
|
||||
dialogProps: DestructiveConfirmDialogProps
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that returns a `confirm()` function as a drop-in replacement for `window.confirm()`.
|
||||
* Returns `Promise<boolean>` — true if user confirms, false if they cancel.
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
*
|
||||
* async function handleDelete() {
|
||||
* const ok = await confirm({ title: '...', description: '...' })
|
||||
* if (!ok) return
|
||||
* // proceed with deletion
|
||||
* }
|
||||
*
|
||||
* return <><DestructiveConfirmDialog {...dialogProps} /></>
|
||||
* ```
|
||||
*/
|
||||
export function useDestructiveConfirm(): UseDestructiveConfirmReturn {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [options, setOptions] = useState<ConfirmOptions>({
|
||||
title: '',
|
||||
description: '',
|
||||
})
|
||||
const resolveRef = useRef<((value: boolean) => void) | null>(null)
|
||||
|
||||
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
|
||||
setOptions(opts)
|
||||
setOpen(true)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleOpenChange = useCallback((v: boolean) => {
|
||||
setOpen(v)
|
||||
if (!v && resolveRef.current) {
|
||||
resolveRef.current(false)
|
||||
resolveRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current(true)
|
||||
resolveRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
dialogProps: {
|
||||
open,
|
||||
onOpenChange: handleOpenChange,
|
||||
title: options.title,
|
||||
description: options.description,
|
||||
confirmLabel: options.confirmLabel,
|
||||
cancelLabel: options.cancelLabel,
|
||||
variant: options.variant,
|
||||
onConfirm: handleConfirm,
|
||||
},
|
||||
confirm,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user