Files
accounted/components/settings/PeppolReceiveSettings.tsx
T
Jakob Wennberg a5c8f55127 fix(settings): keep the Peppol access request inside the settings column (#1796)
The request checkbox and button sat in a shrink-0 end slot next to the
status text, so at the settings column's width the row grew wider than
the panel and pushed every other row's label out of view. The controls
now sit under the status text in a wrapping line.


Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:12:31 +02:00

230 lines
8.8 KiB
TypeScript

'use client'
import { useTranslations } from 'next-intl'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsGroup,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { useLocale } from 'next-intl'
interface PeppolRegistrationView {
participant_scheme: string
participant_identifier: string
status: 'pending' | 'registered' | 'failed' | 'deregistered'
registered_at: string | null
last_error: string | null
}
interface PeppolAccessView {
status: 'none' | 'requested' | 'enabled' | 'disabled'
send_enabled: boolean
receive_enabled: boolean
max_sends: number | null
sent_count: number
remaining_sends: number | null
}
interface PeppolSettingsPayload {
transport: { available: boolean }
receiving_supported: boolean
access: PeppolAccessView
registration: PeppolRegistrationView | null
}
/**
* E-invoicing via Peppol for one company. Access is granted per company by
* the operators (it costs per document and receiving consumes a contracted
* slot), so the first row is the grant itself: ask, wait, see what you got.
* Receiving is a second, separate grant and its switch publishes the
* company's 0007:orgnr through the Access Point.
*/
export function PeppolReceiveSettings() {
const t = useTranslations('settings_peppol')
const locale = useLocale()
const { toast } = useToast()
const canWrite = useCanWrite()
const [state, setState] = useState<PeppolSettingsPayload | null>(null)
const [loadFailed, setLoadFailed] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isRequesting, setIsRequesting] = useState(false)
const [wantsReceiving, setWantsReceiving] = useState(false)
const load = useCallback(async () => {
try {
const response = await fetch('/api/settings/peppol')
if (!response.ok) throw new Error()
const payload = (await response.json()) as { data?: PeppolSettingsPayload }
if (!payload.data) throw new Error()
setState(payload.data)
setLoadFailed(false)
} catch {
setLoadFailed(true)
}
}, [])
useEffect(() => {
void load()
}, [load])
const localeKey = locale.startsWith('sv') ? 'sv' : 'en'
const access = state?.access ?? null
const registration = state?.registration ?? null
const isOn = registration?.status === 'registered' || registration?.status === 'pending'
const transportAvailable = !!state?.transport.available
const receivingAvailable = transportAvailable && !!state?.receiving_supported && !!access?.receive_enabled
const requestAccess = useCallback(async () => {
setIsRequesting(true)
try {
const response = await fetch('/api/settings/peppol/access', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wants_receiving: wantsReceiving }),
})
const body = await response.json().catch(() => null) as {
error?: { code?: string; message?: string; message_en?: string }
} | null
if (!response.ok) throw body?.error ?? new Error()
toast({ title: t('request_sent_title'), description: t('request_sent_description') })
await load()
} catch (error) {
toast({
title: t('request_failed_title'),
description: getUserErrorMessage(error, { locale: localeKey }),
variant: 'destructive',
})
} finally {
setIsRequesting(false)
}
}, [load, localeKey, t, toast, wantsReceiving])
const toggleReceiving = useCallback(async (next: boolean) => {
setIsSaving(true)
try {
const response = await fetch('/api/settings/peppol', { method: next ? 'POST' : 'DELETE' })
const body = await response.json().catch(() => null) as {
error?: { code?: string; message?: string; message_en?: string }
} | null
if (!response.ok) throw body?.error ?? new Error()
toast({
title: next ? t('toast_registered_title') : t('toast_deregistered_title'),
description: next ? t('toast_registered_description') : t('toast_deregistered_description'),
})
await load()
} catch (error) {
toast({
title: t('toast_failed_title'),
description: getUserErrorMessage(error, { locale: localeKey }),
variant: 'destructive',
})
await load()
} finally {
setIsSaving(false)
}
}, [load, localeKey, t, toast])
const accessLine = (() => {
if (!access) return null
switch (access.status) {
case 'enabled': return access.receive_enabled ? t('access_enabled_receiving') : t('access_enabled_send_only')
case 'requested': return t('access_requested')
case 'disabled': return t('access_disabled')
default: return t('access_none')
}
})()
const sendsLine = access?.send_enabled
? access.max_sends === null
? t('sends_unlimited', { used: access.sent_count })
: t('sends_used', { used: access.sent_count, max: access.max_sends })
: null
const registrationStatusLabel = !registration || registration.status === 'deregistered'
? t('status_off')
: t(`status_${registration.status}`)
return (
<SettingsGroup label={t('heading')}>
<SettingsRow label={t('access_label')} align="baseline">
<div className="min-w-0 flex-1 space-y-2 text-sm">
{loadFailed ? (
<SettingsRowNote>{t('load_failed')}</SettingsRowNote>
) : state === null ? (
<SettingsRowNote>{t('loading')}</SettingsRowNote>
) : !transportAvailable ? (
<SettingsRowNote>{t('provider_required')}</SettingsRowNote>
) : (
<div className="space-y-1">
<span className="block">{accessLine}</span>
{sendsLine && <SettingsRowNote className="block tabular-nums">{sendsLine}</SettingsRowNote>}
</div>
)}
{/* The request controls live under the status text and wrap: a long
checkbox label next to a button in a shrink-0 end slot pushed the
whole settings panel wider than its column. */}
{state !== null && transportAvailable && (access?.status === 'none' || access?.status === 'disabled') && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<label className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
className="h-4 w-4 shrink-0 rounded-sm border-border"
checked={wantsReceiving}
onChange={(event) => setWantsReceiving(event.target.checked)}
disabled={isRequesting || !canWrite}
/>
<span>{t('request_receiving_label')}</span>
</label>
<Button
type="button"
variant="outline"
className="ml-auto"
onClick={() => void requestAccess()}
disabled={isRequesting || !canWrite}
>
{isRequesting ? t('request_sending') : t('request_button')}
</Button>
</div>
)}
</div>
</SettingsRow>
{/* Receiving is a separate grant (one contracted slot each): the switch
exists only once the operators granted it, or a registration already
exists that the company must be able to see and withdraw. */}
{(receivingAvailable || isOn) && (
<>
<SettingsRow label={t('enable_label')} help={t('enable_help')}>
<SettingsRowEnd>
<Switch
checked={isOn}
onCheckedChange={(value) => void toggleReceiving(value)}
disabled={isSaving || !canWrite || !receivingAvailable || state === null}
aria-label={t('enable_label')}
/>
</SettingsRowEnd>
</SettingsRow>
<SettingsRow label={t('status_label')} borderless>
<div className="min-w-0 space-y-1 text-sm">
<span>{registrationStatusLabel}</span>
{registration && registration.status !== 'deregistered' && (
<SettingsRowNote className="block tabular-nums">
{t('peppol_id_label')} {registration.participant_scheme}:{registration.participant_identifier}
</SettingsRowNote>
)}
{registration?.status === 'failed' && registration.last_error && (
<SettingsRowNote className="block">{registration.last_error}</SettingsRowNote>
)}
</div>
</SettingsRow>
</>
)}
</SettingsGroup>
)
}