'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocale, useTranslations } from 'next-intl' import { AlertTriangle, Ban, CheckCircle2, Download, ExternalLink, FileDown, Loader2, } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { ContextPicker } from '@/components/common/ContextPicker' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Skeleton } from '@/components/ui/skeleton' import { useToast } from '@/components/ui/use-toast' import { downloadFile, saveBlobToDisk } from '@/lib/browser/download-file' import { failureDescription } from '@/lib/browser/action-failure' import { getErrorMessage, getResponseErrorMessage, type ErrorLocale, } from '@/lib/errors/get-error-message' import { formatCurrency, formatDate } from '@/lib/utils' type DeductionType = 'rot' | 'rut' type RequestStatus = | 'generated' | 'submitted' | 'paid' | 'partially_paid' | 'rejected' | 'cancelled' interface Candidate { invoice_id: string invoice_number: string | null customer_name: string | null personnummer_last4: string betalnings_datum: string pris_for_arbete: number begart_belopp: number } interface BlockedCandidate { invoice_id: string invoice_number: string | null customer_name: string | null code: string message: string } interface PayoutRequest { id: string name: string deduction_type: DeductionType status: RequestStatus requested_total: number | string decided_total: number | string | null file_name: string file_document_id: string | null created_at: string submitted_at: string | null decided_at: string | null items: Array<{ id: string invoice_id: string requested_amount: number | string decided_amount: number | string | null invoice: { id: string; invoice_number: string | null } | null }> } interface RotRutPayoutDialogProps { open: boolean onOpenChange: (open: boolean) => void canWrite: boolean } const MAX_CASES_PER_FILE = 100 const SKATTEVERKET_SERVICE_URL = 'https://www.skatteverket.se/foretag/etjansterochblanketter/allaetjanster/tjanster/rotochrutforetag.4.361dc8c15312eff6fdfca4.html' const STATUS_VARIANT: Record< RequestStatus, 'secondary' | 'outline' | 'success' | 'warning' | 'destructive' > = { generated: 'warning', submitted: 'outline', paid: 'success', partially_paid: 'warning', rejected: 'destructive', cancelled: 'secondary', } export default function RotRutPayoutDialog({ open, onOpenChange, canWrite, }: RotRutPayoutDialogProps) { const t = useTranslations('invoices') const locale = useLocale() as ErrorLocale const { toast } = useToast() const loadSequence = useRef(0) const [type, setType] = useState('rot') const [eligible, setEligible] = useState([]) const [blocked, setBlocked] = useState([]) const [requests, setRequests] = useState([]) const [selectedYear, setSelectedYear] = useState('') const [selectedIds, setSelectedIds] = useState>(new Set()) const [loading, setLoading] = useState(false) const [generating, setGenerating] = useState(false) const [updatingId, setUpdatingId] = useState(null) const [downloadingId, setDownloadingId] = useState(null) const load = useCallback( async (nextType: DeductionType) => { const sequence = ++loadSequence.current setLoading(true) setSelectedIds(new Set()) try { const [eligibleResponse, requestsResponse] = await Promise.all([ fetch(`/api/rot-rut/eligible?type=${nextType}`), fetch(`/api/rot-rut/payout-requests?type=${nextType}`), ]) const failedResponse = !eligibleResponse.ok ? eligibleResponse : !requestsResponse.ok ? requestsResponse : null if (failedResponse) { const description = await getResponseErrorMessage(failedResponse, 'invoice', locale) if (sequence !== loadSequence.current) return setEligible([]) setBlocked([]) setRequests([]) setSelectedYear('') toast({ title: t('rot_rut_load_failed_title'), description, variant: 'destructive' }) return } const eligibleBody = (await eligibleResponse.json()) as { data: { eligible: Candidate[]; blocked: BlockedCandidate[] } } const requestsBody = (await requestsResponse.json()) as { data: PayoutRequest[] } if (sequence !== loadSequence.current) return const nextEligible = eligibleBody.data.eligible const years = Array.from( new Set(nextEligible.map((candidate) => candidate.betalnings_datum.slice(0, 4))), ).sort((a, b) => b.localeCompare(a)) setEligible(nextEligible) setBlocked(eligibleBody.data.blocked) setRequests(requestsBody.data) setSelectedYear(years[0] ?? '') } catch (error) { if (sequence !== loadSequence.current) return setEligible([]) setBlocked([]) setRequests([]) setSelectedYear('') toast({ title: t('rot_rut_load_failed_title'), description: getErrorMessage(error, { context: 'invoice', locale }), variant: 'destructive', }) } finally { if (sequence === loadSequence.current) setLoading(false) } }, [locale, t, toast], ) useEffect(() => { if (open) void load(type) }, [load, open, type]) const years = useMemo( () => Array.from( new Set(eligible.map((candidate) => candidate.betalnings_datum.slice(0, 4))), ).sort((a, b) => b.localeCompare(a)), [eligible], ) const visibleCandidates = useMemo( () => eligible.filter((candidate) => candidate.betalnings_datum.startsWith(selectedYear)), [eligible, selectedYear], ) const selectedTotal = visibleCandidates .filter((candidate) => selectedIds.has(candidate.invoice_id)) .reduce((sum, candidate) => sum + Number(candidate.begart_belopp), 0) function changeType(nextType: DeductionType) { setType(nextType) } function changeYear(year: string) { setSelectedYear(year) setSelectedIds(new Set()) } function toggleCandidate(invoiceId: string, checked: boolean) { setSelectedIds((current) => { const next = new Set(current) if (checked) { if (next.size >= MAX_CASES_PER_FILE) return current next.add(invoiceId) } else { next.delete(invoiceId) } return next }) } function selectAllVisible() { const visibleIds = visibleCandidates .slice(0, MAX_CASES_PER_FILE) .map((candidate) => candidate.invoice_id) const allSelected = visibleIds.every((id) => selectedIds.has(id)) setSelectedIds(allSelected ? new Set() : new Set(visibleIds)) } async function generateFile() { if (!canWrite || generating || selectedIds.size === 0) return setGenerating(true) try { const response = await fetch('/api/rot-rut/payout-file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ deduction_type: type, invoice_ids: Array.from(selectedIds), }), }) if (!response.ok) { toast({ title: t('rot_rut_generate_failed_title'), description: await getResponseErrorMessage(response, 'invoice', locale), variant: 'destructive', }) return } const body = (await response.json()) as { data: { xml: string; file_name: string; warnings: string[] } } saveBlobToDisk( new Blob([body.data.xml], { type: 'application/xml;charset=utf-8' }), body.data.file_name, ) toast({ title: t('rot_rut_generated_title'), description: body.data.warnings.length > 0 ? body.data.warnings.join(' ') : t('rot_rut_generated_description'), }) await load(type) } catch (error) { toast({ title: t('rot_rut_generate_failed_title'), description: getErrorMessage(error, { context: 'invoice', locale }), variant: 'destructive', }) } finally { setGenerating(false) } } async function updateRequest(requestId: string, status: 'submitted' | 'cancelled') { if (!canWrite || updatingId) return setUpdatingId(requestId) try { const response = await fetch(`/api/rot-rut/payout-requests/${requestId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }), }) if (!response.ok) { toast({ title: t('rot_rut_update_failed_title'), description: await getResponseErrorMessage(response, 'invoice', locale), variant: 'destructive', }) return } toast({ title: t(status === 'submitted' ? 'rot_rut_uploaded_title' : 'rot_rut_cancelled_title') }) await load(type) } catch (error) { toast({ title: t('rot_rut_update_failed_title'), description: getErrorMessage(error, { context: 'invoice', locale }), variant: 'destructive', }) } finally { setUpdatingId(null) } } async function downloadArchivedFile(request: PayoutRequest) { if (!request.file_document_id || downloadingId) return setDownloadingId(request.id) try { const result = await downloadFile({ url: `/api/documents/${request.file_document_id}/inline`, filename: request.file_name, locale, }) if (!result.ok) { toast({ title: t('rot_rut_download_failed_title'), description: failureDescription(result, { timeout: t('rot_rut_download_timeout'), network: t('rot_rut_download_network'), }), variant: 'destructive', }) } } finally { setDownloadingId(null) } } return ( {t('rot_rut_payout_title')} {t('rot_rut_payout_description')}
changeType(id as DeductionType)} triggerLabel={t(type === 'rot' ? 'rot_rut_type_rot' : 'rot_rut_type_rut')} ariaLabel={t('rot_rut_type_aria')} items={[ { id: 'rot', label: t('rot_rut_type_rot') }, { id: 'rut', label: t('rot_rut_type_rut') }, ]} disabled={loading || generating} /> {years.length > 0 && ( ({ id: year, label: year }))} disabled={loading || generating} /> )}
{loading ? (
) : (

{t('rot_rut_eligible_title')}

{t('rot_rut_selected_count', { selected: selectedIds.size, amount: formatCurrency(selectedTotal), })}

{visibleCandidates.length > 0 && ( )}
{visibleCandidates.length === 0 ? (

{t('rot_rut_no_eligible_title')}

{t('rot_rut_no_eligible_description')}

) : (
{visibleCandidates.map((candidate) => { const checkboxId = `rot-rut-${candidate.invoice_id}` const checked = selectedIds.has(candidate.invoice_id) const atLimit = selectedIds.size >= MAX_CASES_PER_FILE && !checked return ( ) })}
)} {visibleCandidates.length > MAX_CASES_PER_FILE && (
{t('rot_rut_max_cases_help', { count: MAX_CASES_PER_FILE })}
)}
{blocked.length > 0 && (
{t('rot_rut_blocked_title', { count: blocked.length })}
{blocked.map((candidate) => (
{candidate.invoice_number ?? '-'} · {candidate.customer_name ?? '-'} {candidate.message}
))}
)}

{t('rot_rut_history_title')}

{t('rot_rut_upload_help')}

{requests.length === 0 ? (

{t('rot_rut_history_empty')}

) : (
{requests.map((request) => { const isUpdating = updatingId === request.id const isDownloading = downloadingId === request.id return (
{request.name} {t(`rot_rut_status_${request.status}`)}

{t('rot_rut_history_meta', { date: formatDate(request.created_at), count: request.items.length, amount: formatCurrency(Number(request.requested_total)), })}

{request.file_document_id && ( )} {request.status === 'generated' && canWrite && ( <> )}
) })}
)}
)}
) }