* fix(rot-rut): surface drop-out reasons in payout request dialog and keep selectors usable (#1884) Four silent drop paths made a paid RUT invoice invisible in the begaran dialog (neither eligible nor blocked), and the empty list hid the year picker so the dialog looked dead: 1. deduction lines without a header deduction_total: a second line-based candidate query now finds them and they block as DEDUCTION_TOTAL_MISSING (also at file generation: the 1513 receivable was never booked). 2. partially_paid with the customer share settled: remaining_amount = 0 (total - paid_amount - deduction_total, migration 20260817191708) now counts as paid in evaluateInvoiceForFile; a genuine partial blocks as NOT_PAID with the outstanding amount. 3. NO_DEDUCTION_OF_TYPE is no longer filtered out of blocked: the message points at the other type, and the dialog's empty state adds a switch-type hint. 4. invoices held by a generated/submitted begaran block as ALREADY_REQUESTED naming the request; decided requests stay omitted (finished business, visible in the history list). The dialog keeps the year picker rendered when the list is empty (current year as inert fallback) and opens the blocked list by default when nothing is eligible. Fixes #1884 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): skeptic hardening: decided requests vanish on both tabs, customer share derived from header fields (#1884) Two skeptic refutations against the frozen PR head: 1. Regression: the wrong-type branch ran before the active-request lookup, so invoices of the OTHER type whose begaran was already decided resurfaced forever as NO_DEDUCTION_OF_TYPE in the opposite tab's blocked list, and the empty-state hint pointed at a tab where they never appear. The decided-request skip now runs first, on every tab. 2. Correctness: the paid gate and the NOT_PAID message trusted remaining_amount, but payment-sync's storno path recomputes it WITHOUT subtracting deduction_total, so the stored column can carry Skatteverkets 1513 share and the dialog could assert a wrong customer-outstanding figure. The gate now derives the customer share as total - paid_amount - deduction_total (the buildInvoiceWriteData / migration 20260817191708 formula) from fields every settlement path maintains. Tests pin both: decided+wrong-type omitted from both lists, corrupted remaining still classified and reported from the derived share. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(rot-rut): align CANDIDATE_STATUSES comment with the derived-share gate (#1884) The skeptic-hardening commit moved the paid gate off remaining_amount to the derived customer share (total - paid_amount - deduction_total); the comment still named remaining_amount as the signal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): explicit decided-status set + correction-path wording (#1884) Swedish accounting review findings on the candidate list: 1. The decided-begaran skip inferred 'decided' by exclusion (anything not generated/submitted), so a future request status would make an invoice vanish from both lists, exactly the silent drop the module forbids. DECIDED_REQUEST_STATUSES now names paid/partially_paid; any other status held by a request lands in blocked as ALREADY_REQUESTED with a generic message. Test pins it. 2. The DEDUCTION_TOTAL_MISSING message said only 'ratta fakturan', which could read as an invitation to edit a booked invoice directly. The invoice edit route already refuses sent/paid/booked invoices, and the message now names the sanctioned path: drafts edit directly, sent or paid invoices are corrected via credit note + new invoice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
616 lines
23 KiB
TypeScript
616 lines
23 KiB
TypeScript
'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<DeductionType>('rot')
|
|
const [eligible, setEligible] = useState<Candidate[]>([])
|
|
const [blocked, setBlocked] = useState<BlockedCandidate[]>([])
|
|
const [requests, setRequests] = useState<PayoutRequest[]>([])
|
|
const [selectedYear, setSelectedYear] = useState('')
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
const [loading, setLoading] = useState(false)
|
|
const [generating, setGenerating] = useState(false)
|
|
const [updatingId, setUpdatingId] = useState<string | null>(null)
|
|
const [downloadingId, setDownloadingId] = useState<string | null>(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],
|
|
)
|
|
// The year picker must never disappear: an empty eligible list used to hide
|
|
// it, making the whole dialog look dead even though the type picker worked
|
|
// (#1884). With no eligible years the current year stands in as the only,
|
|
// inert, choice.
|
|
const fallbackYear = String(new Date().getFullYear())
|
|
const yearItems = (years.length > 0 ? years : [fallbackYear]).map((year) => ({
|
|
id: year,
|
|
label: year,
|
|
}))
|
|
const yearValue = selectedYear || fallbackYear
|
|
const otherTypeBlockedCount = useMemo(
|
|
() => blocked.filter((candidate) => candidate.code === 'NO_DEDUCTION_OF_TYPE').length,
|
|
[blocked],
|
|
)
|
|
const otherBlockedCount = blocked.length - otherTypeBlockedCount
|
|
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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-3xl">
|
|
<DialogHeader className="pr-8">
|
|
<DialogTitle>{t('rot_rut_payout_title')}</DialogTitle>
|
|
<DialogDescription>{t('rot_rut_payout_description')}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<ContextPicker
|
|
value={type}
|
|
onChange={(id) => 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}
|
|
/>
|
|
<ContextPicker
|
|
value={yearValue}
|
|
onChange={changeYear}
|
|
triggerLabel={yearValue}
|
|
ariaLabel={t('rot_rut_year_aria')}
|
|
items={yearItems}
|
|
disabled={loading || generating}
|
|
/>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="space-y-3 py-2" role="status" aria-label={t('rot_rut_loading')}>
|
|
<Skeleton className="h-9 w-full" />
|
|
<Skeleton className="h-16 w-full" />
|
|
<Skeleton className="h-16 w-full" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
<section className="space-y-3" aria-labelledby="rot-rut-candidates-title">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<h2 id="rot-rut-candidates-title" className="text-sm">
|
|
{t('rot_rut_eligible_title')}
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('rot_rut_selected_count', {
|
|
selected: selectedIds.size,
|
|
amount: formatCurrency(selectedTotal),
|
|
})}
|
|
</p>
|
|
</div>
|
|
{visibleCandidates.length > 0 && (
|
|
<Button type="button" variant="ghost" size="sm" onClick={selectAllVisible}>
|
|
{visibleCandidates
|
|
.slice(0, MAX_CASES_PER_FILE)
|
|
.every((candidate) => selectedIds.has(candidate.invoice_id))
|
|
? t('rot_rut_clear_selection')
|
|
: t('rot_rut_select_all')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{visibleCandidates.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed p-5 text-center">
|
|
<p className="text-sm font-medium">{t('rot_rut_no_eligible_title')}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{t('rot_rut_no_eligible_description')}
|
|
</p>
|
|
{otherTypeBlockedCount > 0 && (
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
{t('rot_rut_other_type_hint', {
|
|
count: otherTypeBlockedCount,
|
|
type: t(type === 'rot' ? 'rot_rut_type_rut' : 'rot_rut_type_rot'),
|
|
})}
|
|
</p>
|
|
)}
|
|
{otherBlockedCount > 0 && (
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
{t('rot_rut_blocked_below_hint', { count: otherBlockedCount })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="max-h-64 divide-y overflow-y-auto rounded-lg border">
|
|
{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 (
|
|
<label
|
|
key={candidate.invoice_id}
|
|
htmlFor={checkboxId}
|
|
className="flex min-h-14 cursor-pointer items-center gap-3 px-3 py-2.5 hover:bg-secondary/35"
|
|
>
|
|
<Checkbox
|
|
id={checkboxId}
|
|
checked={checked}
|
|
disabled={atLimit || generating || !canWrite}
|
|
onCheckedChange={(value) =>
|
|
toggleCandidate(candidate.invoice_id, value === true)
|
|
}
|
|
/>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-medium">
|
|
{candidate.invoice_number ?? '-'} · {candidate.customer_name ?? '-'}
|
|
</span>
|
|
<span className="block text-xs text-muted-foreground">
|
|
{t('rot_rut_paid_at', { date: formatDate(candidate.betalnings_datum) })}
|
|
{' · ****'}{candidate.personnummer_last4}
|
|
</span>
|
|
</span>
|
|
<span className="shrink-0 text-sm tabular-nums">
|
|
{formatCurrency(Number(candidate.begart_belopp))}
|
|
</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{visibleCandidates.length > MAX_CASES_PER_FILE && (
|
|
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-attn" />
|
|
<span>{t('rot_rut_max_cases_help', { count: MAX_CASES_PER_FILE })}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
type="button"
|
|
onClick={generateFile}
|
|
disabled={!canWrite || generating || selectedIds.size === 0}
|
|
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
|
>
|
|
{generating ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<FileDown className="mr-2 h-4 w-4" />
|
|
)}
|
|
{t(generating ? 'rot_rut_generating_file' : 'rot_rut_generate_file')}
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
|
|
{blocked.length > 0 && (
|
|
<details
|
|
className="rounded-lg border border-dashed px-3 py-2.5"
|
|
// With nothing eligible the reasons ARE the content: start
|
|
// expanded so the dialog explains itself instead of looking
|
|
// empty. The element stays a plain details, so the user can
|
|
// still collapse it.
|
|
open={visibleCandidates.length === 0 || undefined}
|
|
>
|
|
<summary className="cursor-pointer text-sm font-medium">
|
|
{t('rot_rut_blocked_title', { count: blocked.length })}
|
|
</summary>
|
|
<div className="mt-3 space-y-2">
|
|
{blocked.map((candidate) => (
|
|
<div key={candidate.invoice_id} className="flex items-start gap-2 text-xs">
|
|
<Ban className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<span>
|
|
<span className="font-medium text-foreground">
|
|
{candidate.invoice_number ?? '-'} · {candidate.customer_name ?? '-'}
|
|
</span>
|
|
<span className="block text-muted-foreground">{candidate.message}</span>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</details>
|
|
)}
|
|
|
|
<section className="space-y-3 border-t pt-5" aria-labelledby="rot-rut-history-title">
|
|
<div>
|
|
<h2 id="rot-rut-history-title" className="text-sm">
|
|
{t('rot_rut_history_title')}
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground">{t('rot_rut_upload_help')}</p>
|
|
</div>
|
|
{requests.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">{t('rot_rut_history_empty')}</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{requests.map((request) => {
|
|
const isUpdating = updatingId === request.id
|
|
const isDownloading = downloadingId === request.id
|
|
return (
|
|
<div key={request.id} className="rounded-lg border p-3">
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="truncate text-sm font-medium">{request.name}</span>
|
|
<Badge variant={STATUS_VARIANT[request.status]} className="font-normal">
|
|
{t(`rot_rut_status_${request.status}`)}
|
|
</Badge>
|
|
</div>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{t('rot_rut_history_meta', {
|
|
date: formatDate(request.created_at),
|
|
count: request.items.length,
|
|
amount: formatCurrency(Number(request.requested_total)),
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{request.file_document_id && (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={isDownloading}
|
|
onClick={() => void downloadArchivedFile(request)}
|
|
>
|
|
{isDownloading ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Download className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
{t('rot_rut_download_again')}
|
|
</Button>
|
|
)}
|
|
{request.status === 'generated' && canWrite && (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={isUpdating}
|
|
onClick={() => void updateRequest(request.id, 'cancelled')}
|
|
>
|
|
{t('rot_rut_cancel_request')}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={isUpdating}
|
|
onClick={() => void updateRequest(request.id, 'submitted')}
|
|
>
|
|
{isUpdating ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
{t('rot_rut_mark_uploaded')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
<Button asChild type="button" variant="outline" className="w-full sm:w-auto">
|
|
<a href={SKATTEVERKET_SERVICE_URL} target="_blank" rel="noopener noreferrer">
|
|
{t('rot_rut_skatteverket_link')}
|
|
<ExternalLink className="ml-2 h-3.5 w-3.5" />
|
|
</a>
|
|
</Button>
|
|
</section>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|