From f8611f2e898baafff4a6916da3ad528fe389ab7f Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:14:34 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/invoices/recurring/page.tsx | 39 ++++++++++++++----- .../salary/employees/[id]/page.tsx | 34 +++++++++++++--- app/(dashboard)/salary/runs/[id]/page.tsx | 35 ++++++++++++++--- components/salary/SalaryCalendar.tsx | 15 ++++++- messages/en.json | 5 +++ messages/sv.json | 5 +++ 6 files changed, 112 insertions(+), 21 deletions(-) diff --git a/app/(dashboard)/invoices/recurring/page.tsx b/app/(dashboard)/invoices/recurring/page.tsx index 4814e25a..fd67d5a3 100644 --- a/app/(dashboard)/invoices/recurring/page.tsx +++ b/app/(dashboard)/invoices/recurring/page.tsx @@ -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([]) const [isLoading, setIsLoading] = useState(true) const [runningId, setRunningId] = useState(null) + const [deletingId, setDeletingId] = useState(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() { @@ -484,6 +504,8 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s )} + + ) } diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 5160400f..70a9dfa5 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -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(null) const [availableEmployees, setAvailableEmployees] = useState([]) @@ -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 + + ) } diff --git a/components/salary/SalaryCalendar.tsx b/components/salary/SalaryCalendar.tsx index 761fe01a..8dacf346 100644 --- a/components/salary/SalaryCalendar.tsx +++ b/components/salary/SalaryCalendar.tsx @@ -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({ }} /> )} + + ) } diff --git a/messages/en.json b/messages/en.json index ed05bfbe..ac99753e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 411019db..06f0d579 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",