refactor(ui): record detail pages as documents, not card piles (#1739)
Bring every record detail page onto the register-detail document grammar from #1624 (DetailSection/DefRow, one status element per the list pages' chips-mark-exceptions rule, one primary next step plus Förhandsgranska visible and everything else behind a ⋯ overflow menu, line tables on the dry-table idiom with the headline total in the serif): - invoices/[id] (11 cards, 13-button toolbar): Kund | Detaljer rows, Fakturarader table + totals, Anteckningar, Betalning, Påminnelser, Utskickshistorik (InvoiceDeliveryHistory flattened); title carries the doc type, related documents become link rows - supplier-invoices/[id], bookkeeping/[id] (serif title instead of font-mono, JournalEntryAttachments variant="section", CorrectionChain flattened), invoices/[id]/credit, assets/[id]/dispose (form as Fönster rows), salary employees/[id] (edit form behind Redigera in a dialog, Ingående saldon collapsed), salary runs/[id] + run panels (Betalfil, Skattebetalning, AGI, förmåner, override) and the payslip page - DetailSection gains an optional help slot (convention 7) Styling/structure only: no API, fetch, validation, state, dialog or permission change; every action stays reachable. 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
Jakob Wennberg
Claude Fable 5
parent
db14ac18cb
commit
a92c492dbe
@@ -3,9 +3,8 @@
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Info } from 'lucide-react'
|
||||
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
|
||||
import { formatDate, formatCurrency } from '@/lib/utils'
|
||||
import { formatDate, formatCurrency, cn } from '@/lib/utils'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import type { JournalEntry, JournalEntryLine } from '@/types'
|
||||
|
||||
@@ -16,14 +15,10 @@ interface Props {
|
||||
|
||||
function useGetRole() {
|
||||
const t = useTranslations('journal_correction')
|
||||
return (entry: JournalEntry): { label: string; color: string } => {
|
||||
if (entry.source_type === 'storno') {
|
||||
return { label: t('role_storno'), color: 'bg-destructive' }
|
||||
}
|
||||
if (entry.source_type === 'correction') {
|
||||
return { label: t('role_correction'), color: 'bg-primary' }
|
||||
}
|
||||
return { label: t('role_original'), color: 'bg-muted-foreground' }
|
||||
return (entry: JournalEntry): string => {
|
||||
if (entry.source_type === 'storno') return t('role_storno')
|
||||
if (entry.source_type === 'correction') return t('role_correction')
|
||||
return t('role_original')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +27,12 @@ function getTotal(entry: JournalEntry): number {
|
||||
return lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The storno/rättelse chain as a flat chronological list: one hairline row
|
||||
* per verifikat (role, voucher, date, description, amount). The page owns the
|
||||
* kicker and puts the explanatory copy behind its "?" (convention 7), so this
|
||||
* renders no heading and no info box of its own.
|
||||
*/
|
||||
export default function CorrectionChain({ currentEntryId, chain }: Props) {
|
||||
const t = useTranslations('journal_correction')
|
||||
const getRole = useGetRole()
|
||||
@@ -44,62 +45,41 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm">{t('title')}</h3>
|
||||
<ul className="divide-y divide-border text-sm">
|
||||
{sorted.map((entry) => {
|
||||
const total = getTotal(entry)
|
||||
const isCurrent = entry.id === currentEntryId
|
||||
// A cancelled entry is residue from an aborted correction attempt:
|
||||
// it was voided before taking effect and its lines were removed, so
|
||||
// it always sums to 0,00. Without the status chip it renders exactly
|
||||
// like a live storno: dim it and say what it is. Live rows carry no
|
||||
// chip at all (chips mark exceptions); the role label says storno
|
||||
// or rättelse.
|
||||
const isCancelled = entry.status === 'cancelled'
|
||||
|
||||
<div className="rounded-lg bg-muted/50 border p-3 flex gap-2 text-sm text-muted-foreground">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<p>{t('info')}</p>
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-0">
|
||||
{/* Vertical line connecting nodes */}
|
||||
<div className="absolute left-[7px] top-3 bottom-3 w-px bg-border" />
|
||||
|
||||
{sorted.map((entry) => {
|
||||
const role = getRole(entry)
|
||||
const total = getTotal(entry)
|
||||
const isCurrent = entry.id === currentEntryId
|
||||
// A cancelled entry is residue from an aborted correction attempt:
|
||||
// it was voided before taking effect and its lines were removed, so
|
||||
// it always sums to 0,00. Without the status badge it renders
|
||||
// exactly like a live storno: dim it and say what it is.
|
||||
const isCancelled = entry.status === 'cancelled'
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={entry.id}
|
||||
href={`/bookkeeping/${entry.id}`}
|
||||
className="block"
|
||||
>
|
||||
<div className={`relative pl-7 py-2 rounded-sm transition-colors hover:bg-muted/50 ${isCurrent ? 'bg-muted/30' : ''} ${isCancelled ? 'opacity-60' : ''}`}>
|
||||
{/* Timeline dot */}
|
||||
<div className={`absolute left-0.5 top-[18px] h-3 w-3 rounded-full border-2 border-background ${isCancelled ? 'bg-muted-foreground' : role.color}`} />
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-muted-foreground">{role.label}</span>
|
||||
<span className="font-mono text-sm">
|
||||
{formatVoucher(entry)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">{formatDate(entry.entry_date)}</span>
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={isCancelled} />
|
||||
{isCurrent && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{t('current')}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ml-auto text-sm tabular-nums text-muted-foreground">
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
{entry.description && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{entry.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
className={cn('flex flex-wrap items-center gap-x-4 gap-y-1 py-2', isCancelled && 'opacity-60')}
|
||||
>
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground">{getRole(entry)}</span>
|
||||
{isCurrent ? (
|
||||
<span className="tabular-nums">{formatVoucher(entry)}</span>
|
||||
) : (
|
||||
<Link href={`/bookkeeping/${entry.id}`} className="tabular-nums hover:underline">
|
||||
{formatVoucher(entry)}
|
||||
</Link>
|
||||
)}
|
||||
<span className="tabular-nums text-muted-foreground">{formatDate(entry.entry_date)}</span>
|
||||
{entry.description && (
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">{entry.description}</span>
|
||||
)}
|
||||
{isCancelled && <JournalEntryStatusBadge entry={entry} />}
|
||||
{isCurrent && <Badge variant="outline">{t('current')}</Badge>}
|
||||
<span className="ml-auto tabular-nums text-muted-foreground">{formatCurrency(total)}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { openDeferredTab } from '@/lib/browser/deferred-tab'
|
||||
import {
|
||||
FileText,
|
||||
@@ -46,6 +47,14 @@ interface DocumentRecord {
|
||||
interface JournalEntryAttachmentsProps {
|
||||
journalEntryId: string
|
||||
onCountChange?: (count: number) => void
|
||||
/**
|
||||
* 'section': embedded under a DetailSection kicker that already names the
|
||||
* group and states the empty case (verifikat detail page). Drops the own
|
||||
* border, heading and empty line so nothing is said twice; the action
|
||||
* buttons and the document list stay. Default keeps the self-contained
|
||||
* foldout look used by JournalEntryList.
|
||||
*/
|
||||
variant?: 'default' | 'section'
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -69,7 +78,9 @@ function isPreviewable(type: string | null): boolean {
|
||||
export default function JournalEntryAttachments({
|
||||
journalEntryId,
|
||||
onCountChange,
|
||||
variant = 'default',
|
||||
}: JournalEntryAttachmentsProps) {
|
||||
const embedded = variant === 'section'
|
||||
const t = useTranslations('journal_attachments')
|
||||
const tCommon = useTranslations('common')
|
||||
const { toast } = useToast()
|
||||
@@ -251,7 +262,7 @@ export default function JournalEntryAttachments({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<div className={embedded ? undefined : 'border-t pt-3 mt-3'}>
|
||||
<input
|
||||
ref={replaceFileInputRef}
|
||||
type="file"
|
||||
@@ -260,10 +271,12 @@ export default function JournalEntryAttachments({
|
||||
onChange={(e) => handleReplaceFileSelected(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium">
|
||||
{t('title')} {documents.length > 0 && `(${documents.length})`}
|
||||
</h4>
|
||||
<div className={cn('mb-2 flex items-center gap-2', embedded ? 'justify-end' : 'justify-between')}>
|
||||
{!embedded && (
|
||||
<h4 className="text-sm font-medium">
|
||||
{t('title')} {documents.length > 0 && `(${documents.length})`}
|
||||
</h4>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -298,9 +311,11 @@ export default function JournalEntryAttachments({
|
||||
)}
|
||||
|
||||
{documents.length === 0 && !showUpload ? (
|
||||
<p className="text-sm text-muted-foreground py-1">
|
||||
{t('empty')}
|
||||
</p>
|
||||
!embedded && (
|
||||
<p className="text-sm text-muted-foreground py-1">
|
||||
{t('empty')}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{documents.map((doc) => {
|
||||
|
||||
Reference in New Issue
Block a user