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:
co-authored by
Claude Fable 5
parent
a95872928e
commit
f8611f2e89
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user