feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)
* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2) A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an account was reconciled against to (account_key, through_date), before or after the sign-off, from every account body on the Avstämning page. Rows live in account_reconciliation_attachments (append-only, removal stamp by trigger, RLS like account_reconciliations), bytes in the documents bucket under the company prefix so its RLS applies unchanged, and the full archive copies them into bilagor/ with a hash manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867) * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) The bokslut checklist is a catalogue in code with one state row per period (bokslut_checklist_items): the steps the system can judge (sign-offs through balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are computed live and a stored row only overrides them; the manual steps are the konsult's ticks, with who and when. It sits on the wizard's Kontroll step and is dumped into the full archive. A hole between fiscal years (one-file SIE migrations) is now named on the bokslut readiness screen and on the import result screen, where the next file is one click away. Non-adjacent period links are #1849's fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 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> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
ebbdf96b74
commit
c62321988b
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { ClipboardCheck } from 'lucide-react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
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 type { BokslutChecklist as Checklist, ChecklistGroup, ChecklistItem, ChecklistState } from '@/lib/bokslut/checklist'
|
||||
|
||||
/**
|
||||
* The bokslut checklist on the wizard's Kontroll step: every closing step
|
||||
* grouped as the work goes, the system-judged ones read-only with their
|
||||
* computed state, the manual ones ticked here and stored per period
|
||||
* (bokslut_checklist_items). Any step can be marked not applicable.
|
||||
*/
|
||||
|
||||
const GROUP_ORDER: ChecklistGroup[] = ['avstamning', 'periodisering', 'vardering', 'dispositioner', 'kontroll', 'rapportering']
|
||||
|
||||
interface BokslutChecklistProps {
|
||||
periodId: string
|
||||
}
|
||||
|
||||
export function BokslutChecklist({ periodId }: BokslutChecklistProps) {
|
||||
const t = useTranslations('bokslut_checklist')
|
||||
const locale = useLocale()
|
||||
const { toast } = useToast()
|
||||
const [checklist, setChecklist] = useState<Checklist | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const base = `/api/bookkeeping/fiscal-periods/${periodId}/bokslut-checklist`
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(base)
|
||||
if (!res.ok) {
|
||||
setFailed(true)
|
||||
return
|
||||
}
|
||||
const json = await res.json()
|
||||
setChecklist(json.data as Checklist)
|
||||
setFailed(false)
|
||||
} catch {
|
||||
setFailed(true)
|
||||
}
|
||||
}, [base])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
async function set(item: ChecklistItem, state: ChecklistState) {
|
||||
setBusy(item.key)
|
||||
try {
|
||||
const res = await fetch(base, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ item_key: item.key, state }),
|
||||
})
|
||||
const json = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
toast({ title: t('save_failed'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
setChecklist(json.data as Checklist)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) return null
|
||||
if (!checklist) {
|
||||
return (
|
||||
<section aria-busy className="space-y-2">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const label = (item: ChecklistItem) => (locale === 'en' ? item.label_en : item.label_sv)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-1 flex items-center gap-2 px-1">
|
||||
<ClipboardCheck className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
<h3 className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">{t('heading')}</h3>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground" data-ph-mask>
|
||||
{t('progress', { done: checklist.summary.done + checklist.summary.not_applicable, total: checklist.summary.total })}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
</div>
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const items = checklist.items.filter((i) => i.group === group)
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div key={group} className="px-1 pt-2">
|
||||
<p className="text-[11px] font-medium text-muted-foreground">{t(`group_${group}`)}</p>
|
||||
<ul>
|
||||
{items.map((item) => {
|
||||
const done = item.effective_state === 'done'
|
||||
const na = item.effective_state === 'not_applicable'
|
||||
// Auto items are judged by the system; the stored override
|
||||
// (typically "ej tillämpligt") is the only thing to toggle.
|
||||
const toggleable = !item.auto || item.stored_state != null
|
||||
return (
|
||||
<li
|
||||
key={item.key}
|
||||
className="group flex items-start gap-3 border-b border-border/60 py-2.5 text-[13px] leading-5 last:border-b-0"
|
||||
>
|
||||
<Checkbox
|
||||
checked={done}
|
||||
disabled={busy !== null || na || (item.auto && item.stored_state == null)}
|
||||
onCheckedChange={(v) => void set(item, v === true ? 'done' : 'open')}
|
||||
aria-label={label(item)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className={cn('min-w-0 flex-1', na && 'text-muted-foreground line-through')}>
|
||||
{label(item)}
|
||||
{item.auto && item.stored_state == null && (
|
||||
<span className="ml-1.5 rounded-full bg-muted px-1.5 py-px text-[10px] text-muted-foreground no-underline">
|
||||
{t('auto_chip')}
|
||||
</span>
|
||||
)}
|
||||
{item.done_at && !item.auto && (
|
||||
<span className="ml-1.5 text-[11.5px] text-muted-foreground">{t('done_at', { date: formatDate(item.done_at) })}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-3">
|
||||
{item.href && !done && !na && (
|
||||
<Link href={item.href} className={QUIET_LINK_CLASS}>
|
||||
{t('open')}
|
||||
</Link>
|
||||
)}
|
||||
{na ? (
|
||||
<button type="button" onClick={() => void set(item, 'open')} disabled={busy !== null} className={QUIET_LINK_CLASS}>
|
||||
{t('reopen')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void set(item, 'not_applicable')}
|
||||
disabled={busy !== null}
|
||||
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
|
||||
>
|
||||
{t('not_applicable')}
|
||||
</button>
|
||||
)}
|
||||
{toggleable && item.auto && item.stored_state != null && !na && (
|
||||
<button type="button" onClick={() => void set(item, 'open')} disabled={busy !== null} className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}>
|
||||
{t('use_auto')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { AlertTriangle, Info, XCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { BokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator'
|
||||
import { BokslutChecklist } from './BokslutChecklist'
|
||||
|
||||
interface PreflightStepProps {
|
||||
report: BokslutReadinessReport | null
|
||||
@@ -101,6 +102,8 @@ export function PreflightStep({ report, isLoading, error, onContinue }: Prefligh
|
||||
</section>
|
||||
)}
|
||||
|
||||
<BokslutChecklist periodId={report.period.id} />
|
||||
|
||||
{report.reminders.length > 0 && (
|
||||
<section>
|
||||
<SectionHead icon={<Info className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />}>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { findFiscalYearGaps, type FiscalYearGap, type PeriodLike } from '@/lib/bookkeeping/fiscal-year-gaps'
|
||||
|
||||
/**
|
||||
* After a SIE import: the years the company now has, with any hole between
|
||||
* them called out. Fortnox and friends export one räkenskapsår per file, so
|
||||
* "import the next file" is the fix and this is the moment to say it.
|
||||
* Renders nothing while loading, on failure, or when the chain is whole.
|
||||
*/
|
||||
export function FiscalYearGapNotice() {
|
||||
const t = useTranslations('fiscal_year_gaps')
|
||||
const [gaps, setGaps] = useState<FiscalYearGap[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/bookkeeping/fiscal-periods')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return
|
||||
const json = await res.json()
|
||||
const periods = (Array.isArray(json) ? json : (json.data ?? [])) as PeriodLike[]
|
||||
if (!cancelled) setGaps(findFiscalYearGaps(periods))
|
||||
})
|
||||
.catch(() => {
|
||||
// Advisory only.
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (gaps.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card className="border-attn/50">
|
||||
<CardContent className="flex items-start gap-3 pt-6">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-attn" aria-hidden="true" />
|
||||
<div className="space-y-1 text-[13px] leading-5">
|
||||
<p className="font-medium">{t('title', { count: gaps.length })}</p>
|
||||
{gaps.map((gap) => (
|
||||
<p key={`${gap.missing_from}-${gap.missing_to}`} className="text-muted-foreground tabular-nums">
|
||||
{t('gap', { from: gap.missing_from, to: gap.missing_to, after: gap.after.name, before: gap.before.name })}
|
||||
</p>
|
||||
))}
|
||||
<p className="text-muted-foreground">
|
||||
{t('hint')}{' '}
|
||||
<Link href="/settings/bookkeeping" className={QUIET_LINK_CLASS}>
|
||||
{t('manage')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { formatCurrency } from '@/lib/utils'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import TheaterCanvas from '@/components/import/TheaterCanvas'
|
||||
import { FiscalYearGapNotice } from '@/components/import/FiscalYearGapNotice'
|
||||
import type { ImportPreview, ImportResult } from '@/lib/import/types'
|
||||
import type { TheaterModel } from '@/lib/import/theater-model'
|
||||
|
||||
@@ -174,6 +175,9 @@ export default function ImportResultStep({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* A year missing between the imported ones: say so here, where the next file is one click away. */}
|
||||
{result.success && <FiscalYearGapNotice />}
|
||||
|
||||
{/* IB resync notice (prior-year backfill) */}
|
||||
{result.success && result.nextPeriodIBResync && (
|
||||
<Card className="border-success/50">
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
} from '@/lib/reconciliation/schemas'
|
||||
import type { SkattekontoBatchRowResult, SkattekontoTransactionWithSuggestion } from '@/types/skatteverket'
|
||||
import { SignoffDialog, type SignoffSubmitInput } from './SignoffDialog'
|
||||
import { ReconciliationUnderlag } from './ReconciliationUnderlag'
|
||||
import { MatcherPreview, type MatcherMatch } from './MatcherPreview'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
|
||||
@@ -557,6 +558,12 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Underlag for the balansdag in play: the signed date, else the date the next sign-off would cover. */}
|
||||
<ReconciliationUnderlag
|
||||
accountKey={account.account_key}
|
||||
throughDate={status.signoff && status.signoff.through_date >= signoffDefaultDate ? status.signoff.through_date : signoffDefaultDate}
|
||||
/>
|
||||
|
||||
{/* Bridge: how the difference is explained. */}
|
||||
{status.bridge.length > 0 && (
|
||||
<dl className="max-w-[520px] text-[13px]">
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Paperclip } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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-2">
|
||||
<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>
|
||||
{canWrite && (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPT}
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={(e) => void upload(e.target.files)}
|
||||
aria-label={t('attach')}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={busy !== null}
|
||||
aria-busy={busy === 'upload'}
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
{t('attach')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{attachments === null ? null : attachments.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">{t('empty')}</p>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user