Files
accounted/components/ui/destructive-confirm-dialog.tsx
T
Jakob WennbergandClaude Opus 4.6 ceec8c02a8 feat: reverse charge VAT (ruta 20-32) + mobile UX improvements (#50)
* fix: include reversed entries in all reports (general ledger, trial balance, VAT, SIE, NE, INK2)

Reversed entries (storno) must appear alongside their original posted entries
in reports for a complete audit trail. Previously, filtering by status='posted'
excluded them, causing discrepancies when corrections had been made.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: semi-manual invoice payment booking with editable journal lines

When marking an invoice as paid, users now see a dialog where they can:
- Choose which bank/cash account the payment goes to (1910, 1920, 1930, etc.)
- Review and edit the proposed journal entry lines before committing
- The happy path remains fast — lines are pre-filled correctly

Implementation:
- Pure proposePaymentLines() function for line computation (accrual + cash)
- PaymentBookingDialog with AccountCombobox, balance validation, date picker
- API accepts optional custom lines, falls back to auto-generation without them
- 18 tests (8 unit + 10 API) all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — validation fallback, balance check, error handling

- P1: Return 400 on invalid body instead of silently falling back to
  auto-generated lines (split JSON parse from schema validation)
- P1: Add server-side balance check for custom lines before committing
  (debit must equal credit, totalDebit > 0)
- P2: Wrap PaymentBookingDialog init() in try/catch with toast on
  failure and auto-close instead of silent empty state
- Add 2 new tests: unbalanced lines → 400, invalid schema → 400

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: OAuth callback redirect for local dev and timeout resilience

- Pass redirectUri dynamically from NEXT_PUBLIC_APP_URL so OAuth
  callbacks work on localhost (not just production)
- Encode consentId/provider in OAuth state (base64url JSON) so the
  callback doesn't depend on session storage
- Add skipAuth flag to extension API routes for OAuth callbacks
  (external provider redirects have no user session cookie)
- Wrap AbortError in descriptive timeout messages in arcim-client
- Make preview endpoint resilient to partial failures (company info
  and SIE fetch are individually non-blocking)
- Simplify login page (remove unused magic link auth mode)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: create journal entry before marking invoice as paid

Move journal entry creation before the invoice status update so that
if accounting fails, the invoice is not permanently marked paid without
a corresponding entry. Previously the error was silently swallowed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update mark-paid tests for journal-first ordering

Reorder mock queue to match new flow (settings before update), update
failure test to expect 500 instead of silent success, add try-catch
with proper error response in route handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add reverse charge VAT (ruta 20-32) and improve mobile UX across dashboard

Add full reverse charge (omvänd skattskyldighet) support to the VAT declaration:
- Map accounts 2614/2624/2634 to ruta 30/31/32 for self-assessed output VAT
- Calculate purchase bases (ruta 20-24) from supplier invoices by supplier type
- Include ruta 30-32 in ruta 49 formula and totalOutputVat summary
- Display reverse charge section in reports UI and composition chart
- Add comprehensive test coverage for all reverse charge scenarios

Improve mobile UX across the app:
- Convert nav drawer to bottom sheet with drag handle and safe area padding
- Add mobile card layout for PaymentBookingDialog journal lines
- Replace settings tab pills with dropdown selector on mobile
- Make wizard step indicators responsive (collapsed on mobile)
- Ensure all dialog footers stack buttons full-width on mobile
- Add 44px minimum touch targets throughout
- Make onboarding buttons full-width on mobile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — indentation, query efficiency, tab dedup

- Fix misleading try-block indentation in mark-paid route
- Filter reversed entries at DB level (.eq('status', 'posted')) instead
  of fetching then discarding in memory
- Extract shared settingsTabs array so mobile Select and desktop
  TabsList stay in sync automatically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:42:57 +01:00

180 lines
4.6 KiB
TypeScript

'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-destructive/10 text-destructive'
: 'bg-warning/10 text-warning'
)}
>
<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}
className="min-h-11 w-full sm:w-auto"
>
{cancelLabel}
</Button>
<Button
variant={variant === 'destructive' ? 'destructive' : 'default'}
onClick={handleConfirm}
disabled={isLoading}
className={cn(
'min-h-11 w-full sm:w-auto',
variant === 'warning' && 'bg-warning hover:bg-warning/90 text-warning-foreground'
)}
>
{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,
}
}