fix(ui): use styled confirm dialog for salary and recurring-invoice destructive actions (#1036)

* fix(ui): use styled confirm dialog for salary and recurring-invoice destructive actions

Replace native window.confirm() with the existing DestructiveConfirmDialog
/ useDestructiveConfirm() primitive at the six sites from #839: recurring
invoice schedule delete, employee deactivation, salary run draft delete,
remove employee from run, salary calendar bulk delete (all variant
'destructive'), and the nollkorning-to-review guard (variant 'warning').
Confirmation copy is preserved as the dialog description; new title keys
added to both messages/sv.json and messages/en.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): lock delete and deactivate actions while the request is in flight

The styled confirm dialog resolves before the DELETE settles, so the
trigger button could be clicked again and fire a duplicate request.
Add an in-flight guard (deletingId / deactivating) and disable the
button until the request completes, mirroring the runNow pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-16 18:14:34 +02:00
committed by GitHub
parent a95872928e
commit f8611f2e89
6 changed files with 112 additions and 21 deletions
+30 -9
View File
@@ -16,6 +16,10 @@ import {
TableRow,
} from '@/components/ui/table'
import { EmptyState } from '@/components/ui/empty-state'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatDate } from '@/lib/utils'
@@ -31,8 +35,10 @@ export default function RecurringInvoicesPage() {
const [schedules, setSchedules] = useState<ScheduleRow[]>([])
const [isLoading, setIsLoading] = useState(true)
const [runningId, setRunningId] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const { canWrite } = useCanWrite()
const { toast } = useToast()
const { dialogProps, confirm: confirmAction } = useDestructiveConfirm()
const router = useRouter()
const searchParams = useSearchParams()
const t = useTranslations('invoice_recurring')
@@ -129,15 +135,27 @@ export default function RecurringInvoicesPage() {
}
async function deleteSchedule(s: ScheduleRow) {
if (!confirm(t('delete_confirm', { name: s.name }))) {
return
}
const res = await fetch(`/api/invoices/recurring/${s.id}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: t('schedule_deleted_title') })
fetchSchedules()
} else {
toast({ title: t('schedule_delete_failed_title'), variant: 'destructive' })
// In-flight guard: the confirm dialog closes before the DELETE settles,
// so a second click would fire a duplicate request.
if (deletingId) return
const ok = await confirmAction({
title: t('delete_confirm_title'),
description: t('delete_confirm', { name: s.name }),
confirmLabel: t('delete'),
variant: 'destructive',
})
if (!ok) return
setDeletingId(s.id)
try {
const res = await fetch(`/api/invoices/recurring/${s.id}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: t('schedule_deleted_title') })
fetchSchedules()
} else {
toast({ title: t('schedule_delete_failed_title'), variant: 'destructive' })
}
} finally {
setDeletingId(null)
}
}
@@ -259,6 +277,7 @@ export default function RecurringInvoicesPage() {
<Button
variant="ghost"
size="sm"
disabled={deletingId !== null}
onClick={() => deleteSchedule(s)}
>
{t('delete')}
@@ -297,6 +316,8 @@ export default function RecurringInvoicesPage() {
fetchSchedules()
}}
/>
<DestructiveConfirmDialog {...dialogProps} />
</div>
)
}
+28 -6
View File
@@ -11,6 +11,10 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ArrowLeft, Save, Trash2 } from 'lucide-react'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -44,9 +48,11 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
const { dialogProps, confirm: confirmAction } = useDestructiveConfirm()
const [employee, setEmployee] = useState<Employee | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [deactivating, setDeactivating] = useState(false)
const [employmentType, setEmploymentType] = useState('employee')
const [salaryType, setSalaryType] = useState('monthly')
const [vacationRule, setVacationRule] = useState('procentregeln')
@@ -181,12 +187,26 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
}
async function handleDeactivate() {
if (!confirm(t('detail_deactivate_confirm'))) return
// In-flight guard: the confirm dialog closes before the DELETE settles,
// so a second click would fire a duplicate request.
if (deactivating) return
const ok = await confirmAction({
title: t('detail_deactivate_confirm_title'),
description: t('detail_deactivate_confirm'),
confirmLabel: t('detail_deactivate'),
variant: 'destructive',
})
if (!ok) return
const res = await fetch(`/api/salary/employees/${id}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: t('detail_deactivated') })
router.push('/salary/employees')
setDeactivating(true)
try {
const res = await fetch(`/api/salary/employees/${id}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: t('detail_deactivated') })
router.push('/salary/employees')
}
} finally {
setDeactivating(false)
}
}
@@ -226,7 +246,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
</div>
</div>
{canWrite && (
<Button variant="outline" size="sm" onClick={handleDeactivate} className="text-destructive">
<Button variant="outline" size="sm" onClick={handleDeactivate} disabled={deactivating} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
{t('detail_deactivate')}
</Button>
@@ -484,6 +504,8 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
</div>
)}
</form>
<DestructiveConfirmDialog {...dialogProps} />
</div>
)
}
+30 -5
View File
@@ -15,6 +15,10 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { AlertTriangle, Download, Loader2 } from 'lucide-react'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useAgiSubmission } from '@/lib/hooks/use-agi-submission'
@@ -38,6 +42,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
const { toast } = useToast()
const { canWrite } = useCanWrite()
const t = useTranslations('salary_run')
const { dialogProps, confirm: confirmAction } = useDestructiveConfirm()
const [run, setRun] = useState<RunDetail | null>(null)
const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([])
@@ -232,7 +237,13 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
async function handleDelete() {
if (!run) return
const period = periodLabelOf(run)
if (!confirm(t('confirm_delete', { period }))) return
const ok = await confirmAction({
title: t('confirm_delete_title'),
description: t('confirm_delete', { period }),
confirmLabel: t('action_delete_draft'),
variant: 'destructive',
})
if (!ok) return
setActionLoading('delete')
const res = await fetch(`/api/salary/runs/${id}`, { method: 'DELETE' })
if (res.ok) {
@@ -293,7 +304,13 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
// Remove an employee from a draft run. The DELETE endpoint is draft-only and
// cascades to the employee's line items.
async function handleRemoveEmployee(employeeId: string, name: string) {
if (!confirm(t('confirm_remove_employee', { name }))) return
const ok = await confirmAction({
title: t('confirm_remove_employee_title'),
description: t('confirm_remove_employee', { name }),
confirmLabel: t('remove_sr'),
variant: 'destructive',
})
if (!ok) return
setActionLoading(`remove-${employeeId}`)
const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, {
method: 'DELETE',
@@ -512,9 +529,15 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
// Advancing a draft to review. For a nollkörning confirm first: an empty
// declaration is filed to Skatteverket, which should be deliberate.
function handleToReview() {
if (isNollkorning && !confirm(t('confirm_nollkorning'))) {
return
async function handleToReview() {
if (isNollkorning) {
const ok = await confirmAction({
title: t('nollkorning_title'),
description: t('confirm_nollkorning'),
confirmLabel: t('action_to_review'),
variant: 'warning',
})
if (!ok) return
}
handleAction('review')
}
@@ -697,6 +720,8 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
</DialogFooter>
</DialogContent>
</Dialog>
<DestructiveConfirmDialog {...dialogProps} />
</div>
)
}
+14 -1
View File
@@ -29,6 +29,10 @@ import {
} from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import {
Dialog,
DialogContent,
@@ -123,6 +127,7 @@ export function SalaryCalendar({
}: SalaryCalendarProps) {
const t = useTranslations('salary_calendar')
const locale = useLocale()
const { dialogProps, confirm: confirmAction } = useDestructiveConfirm()
const dateLocale = locale === 'en' ? enUS : sv
const isHourly = salaryType === 'hourly'
const periodStartDate = useMemo(() => parseISO(periodStart), [periodStart])
@@ -277,7 +282,13 @@ export function SalaryCalendar({
const handleBulkDelete = async () => {
if (selected.size === 0 || readOnly) return
if (!confirm(t('confirm_bulk_delete', { count: selected.size }))) return
const ok = await confirmAction({
title: t('confirm_bulk_delete_title'),
description: t('confirm_bulk_delete', { count: selected.size }),
confirmLabel: t('delete'),
variant: 'destructive',
})
if (!ok) return
setDeleting(true)
setError(null)
try {
@@ -557,6 +568,8 @@ export function SalaryCalendar({
}}
/>
)}
<DestructiveConfirmDialog {...dialogProps} />
</div>
)
}
+5
View File
@@ -2931,6 +2931,7 @@
"schedule_deleted_title": "Schedule removed",
"schedule_delete_failed_title": "Could not remove schedule",
"delete_confirm": "Remove schedule \"{name}\"? Already created invoices are not affected.",
"delete_confirm_title": "Remove the schedule?",
"run_now": "Create invoice now",
"run_now_confirm": "Create an invoice for \"{name}\" now? If the schedule has automatic sending, it is emailed to the customer immediately. The next scheduled run is not affected.",
"run_now_success_title": "Invoice created",
@@ -4977,7 +4978,9 @@
"journal_th_debit": "Debit",
"journal_th_credit": "Credit",
"confirm_delete": "Delete the draft for {period}? All employees and calculations in the run are removed. This cannot be undone.",
"confirm_delete_title": "Delete draft?",
"confirm_remove_employee": "Remove {name} from the payroll run?",
"confirm_remove_employee_title": "Remove employee?",
"toast_status_updated": "Status updated",
"toast_status_failed": "Could not update status",
"toast_draft_deleted": "Draft deleted",
@@ -5116,6 +5119,7 @@
"error_load_worked": "Could not load worked hours",
"unknown_error": "Unknown error",
"confirm_bulk_delete": "Remove everything (worked time and absence) on {count, plural, one {# day} other {# days}}?",
"confirm_bulk_delete_title": "Remove selected days?",
"error_delete_worked_date": "Could not remove worked time on {date}",
"error_delete_absence_date": "Could not remove absence on {date}",
"prev_month": "Previous month",
@@ -5228,6 +5232,7 @@
"detail_updated": "Employee updated",
"detail_update_failed": "Could not update employee",
"detail_deactivate_confirm": "Do you want to deactivate this employee?",
"detail_deactivate_confirm_title": "Deactivate employee?",
"detail_deactivated": "Employee deactivated",
"detail_not_found": "Employee not found",
"detail_deactivate": "Deactivate",
+5
View File
@@ -2931,6 +2931,7 @@
"schedule_deleted_title": "Schema borttaget",
"schedule_delete_failed_title": "Kunde inte ta bort schema",
"delete_confirm": "Ta bort schemat \"{name}\"? Redan skapade fakturor påverkas inte.",
"delete_confirm_title": "Ta bort schemat?",
"run_now": "Skapa faktura nu",
"run_now_confirm": "Skapa en faktura för \"{name}\" nu? Om schemat har automatiskt utskick skickas den direkt till kunden. Nästa schemalagda körning påverkas inte.",
"run_now_success_title": "Faktura skapad",
@@ -4977,7 +4978,9 @@
"journal_th_debit": "Debet",
"journal_th_credit": "Kredit",
"confirm_delete": "Radera utkastet för {period}? Alla anställda och beräkningar i körningen tas bort. Detta kan inte ångras.",
"confirm_delete_title": "Radera utkast?",
"confirm_remove_employee": "Ta bort {name} från lönekörningen?",
"confirm_remove_employee_title": "Ta bort anställd?",
"toast_status_updated": "Status uppdaterad",
"toast_status_failed": "Kunde inte uppdatera status",
"toast_draft_deleted": "Utkast raderat",
@@ -5116,6 +5119,7 @@
"error_load_worked": "Kunde inte ladda arbetade timmar",
"unknown_error": "Okänt fel",
"confirm_bulk_delete": "Ta bort allt (arbetad tid och frånvaro) på {count, plural, one {# dag} other {# dagar}}?",
"confirm_bulk_delete_title": "Ta bort markerade dagar?",
"error_delete_worked_date": "Kunde inte ta bort arbetad tid på {date}",
"error_delete_absence_date": "Kunde inte ta bort frånvaro på {date}",
"prev_month": "Föregående månad",
@@ -5228,6 +5232,7 @@
"detail_updated": "Anställd uppdaterad",
"detail_update_failed": "Kunde inte uppdatera anställd",
"detail_deactivate_confirm": "Vill du inaktivera denna anställd?",
"detail_deactivate_confirm_title": "Inaktivera anställd?",
"detail_deactivated": "Anställd inaktiverad",
"detail_not_found": "Anställd hittades inte",
"detail_deactivate": "Inaktivera",