Files
accounted/components/reconciliation/ReconciliationUnderlag.tsx
T
43a569d248 fix(reconciliation): declutter the Avstämning header and quiet the underlag section (#1886)
The header keeps only the page scope (räkenskapsår, intervall); the
Översikt/Matcha manuellt switch moves down to the body it switches, and the
Bokslutsbilagor link sits under the account rail it summarizes. The underlag
section is one quiet line when nothing is attached, lists files only when
there are some, and comes after the numbers instead of before them. The
native file input is display:none: sr-only still leaked the browser's
"Ingen fil har valts".


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:43:32 +02:00

180 lines
6.6 KiB
TypeScript

'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Paperclip } from 'lucide-react'
import { QUIET_LINK_CLASS, HOVER_REVEAL_CLASS } from '@/components/ui/dry-table'
import { useToast } from '@/components/ui/use-toast'
import { cn, formatDate } from '@/lib/utils'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { exceedsHostedUploadLimit, isShrinkableImage, tooLargeMessage } from '@/lib/documents/upload-size'
import { shrinkImageForUpload } from '@/lib/documents/shrink-image'
import type { ReconciliationAttachment } from '@/lib/reconciliation/schemas'
/**
* The underlag of one account's balansdag: the files it was reconciled
* against (kontoutdrag, engagemangsbesked, reskontralista, inventering).
* Lists what is attached for the date, attaches more, and lets a member
* withdraw a wrong file (a stamp, the file stays). Reads and writes the
* attachments routes under /api/reconciliation/accounts/{key}/attachments.
*/
interface ReconciliationUnderlagProps {
accountKey: string
/** The balansdag the files document: the sign-off date in play. */
throughDate: string
canWrite?: boolean
}
const ACCEPT = 'application/pdf,image/jpeg,image/png,image/webp'
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} kB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function ReconciliationUnderlag({ accountKey, throughDate, canWrite = true }: ReconciliationUnderlagProps) {
const t = useTranslations('reconciliation_underlag')
const { toast } = useToast()
const [attachments, setAttachments] = useState<ReconciliationAttachment[] | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const base = `/api/reconciliation/accounts/${encodeURIComponent(accountKey)}/attachments`
const load = useCallback(async () => {
try {
const res = await fetch(`${base}?through_date=${throughDate}`)
if (!res.ok) {
setAttachments([])
return
}
const json = await res.json()
setAttachments((json.data?.attachments ?? []) as ReconciliationAttachment[])
} catch {
setAttachments([])
}
}, [base, throughDate])
useEffect(() => {
void load()
}, [load])
async function upload(files: FileList | null) {
if (!files || files.length === 0) return
setBusy('upload')
try {
for (const original of Array.from(files)) {
// Hosted functions refuse bodies over 4.5 MB before the route runs
// (no logs, no message); shrink images, refuse the rest with a reason.
let file = original
if (exceedsHostedUploadLimit(file.size) && isShrinkableImage(file.type)) {
file = await shrinkImageForUpload(file)
}
if (exceedsHostedUploadLimit(file.size)) {
toast({ title: t('too_large_title'), description: tooLargeMessage(file.size), variant: 'destructive' })
continue
}
const form = new FormData()
form.set('file', file)
form.set('through_date', throughDate)
const res = await fetch(base, { method: 'POST', body: form })
const json = await res.json().catch(() => ({}))
if (!res.ok) {
toast({ title: t('upload_failed'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive' })
continue
}
toast({ title: t('attached', { name: file.name }) })
}
await load()
} finally {
setBusy(null)
if (inputRef.current) inputRef.current.value = ''
}
}
async function remove(attachment: ReconciliationAttachment) {
setBusy(attachment.id)
try {
const res = await fetch(`${base}/${attachment.id}`, { method: 'DELETE' })
const json = await res.json().catch(() => ({}))
if (!res.ok) {
toast({ title: t('remove_failed'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive' })
return
}
toast({ title: t('removed', { name: attachment.file_name }) })
await load()
} finally {
setBusy(null)
}
}
return (
<section aria-label={t('heading')} className="space-y-1.5">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="text-[11px] font-medium uppercase tracking-[0.07em] text-muted-foreground">
{t('heading_dated', { date: formatDate(throughDate) })}
</h3>
{attachments !== null && attachments.length === 0 && (
<span className="text-[12.5px] text-muted-foreground" title={t('empty')}>
{t('empty_short')}
</span>
)}
{canWrite && (
<>
<input
ref={inputRef}
type="file"
accept={ACCEPT}
multiple
className="hidden"
onChange={(e) => void upload(e.target.files)}
aria-label={t('attach')}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={busy !== null}
aria-busy={busy === 'upload'}
className={cn(QUIET_LINK_CLASS, 'inline-flex items-center gap-1')}
>
<Paperclip className="h-3 w-3" aria-hidden="true" />
{t('attach')}
</button>
</>
)}
</div>
{attachments === null || attachments.length === 0 ? null : (
<ul className="max-w-[560px] divide-y divide-border/60 text-[13px]">
{attachments.map((a) => (
<li key={a.id} className="group flex items-center gap-3 py-1.5">
<a
href={`${base}/${a.id}`}
target="_blank"
rel="noopener noreferrer"
className={cn(QUIET_LINK_CLASS, 'min-w-0 flex-1 truncate')}
data-ph-mask
>
{a.file_name}
</a>
<span className="shrink-0 text-[11.5px] tabular-nums text-muted-foreground">
{formatSize(a.size_bytes)} · {formatDate(a.uploaded_at)}
</span>
{canWrite && (
<button
type="button"
onClick={() => void remove(a)}
disabled={busy !== null}
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS, 'shrink-0')}
>
{t('remove')}
</button>
)}
</li>
))}
</ul>
)}
</section>
)
}