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