* fix(design): stop synthesizing bold on Hedvig display headings Hedvig Letters Serif ships weight 400 only, but the h1-h3 base rule forced font-weight 500 and DialogTitle/SheetTitle stacked font-semibold on top, so every display heading rendered browser-synthesized bold: the smudged heavy look on dialog titles and page headings. Drop the base rule to 400, remove the weight utilities from the title primitives, and sweep the 41 files that hand-set font-medium/semibold/bold on serif headings (a pattern design.md already forbids). Headings that opt into font-sans keep their weight. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(design): drop empty className left by the weight sweep Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
583 lines
22 KiB
TypeScript
583 lines
22 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],
|
|
)
|
|
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}
|
|
/>
|
|
{years.length > 0 && (
|
|
<ContextPicker
|
|
value={selectedYear}
|
|
onChange={changeYear}
|
|
triggerLabel={selectedYear}
|
|
ariaLabel={t('rot_rut_year_aria')}
|
|
items={years.map((year) => ({ id: year, label: year }))}
|
|
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>
|
|
</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-md 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">
|
|
<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>
|
|
)
|
|
}
|