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:
Jakob Wennberg
2026-08-20 12:29:43 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent db14ac18cb
commit a92c492dbe
28 changed files with 4348 additions and 4228 deletions
+46 -66
View File
@@ -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) => {
+150 -176
View File
@@ -1,11 +1,10 @@
'use client'
import { useFormatter, useTranslations } from 'next-intl'
import { ExternalLink, Mail, Send } from 'lucide-react'
import { ChevronRight, ExternalLink } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import type { InvoiceDelivery, InvoiceDeliveryProviderStatus } from '@/types'
export type InvoiceDeliveryView = Pick<
@@ -52,18 +51,15 @@ function outcomeOf(delivery: InvoiceDeliveryView): DeliveryOutcome {
return delivery.status
}
// Green is reserved for a confirmed arrival. "Skickad" without a delivery
// report is a neutral, honest in-between state.
const outcomeVariant: Record<DeliveryOutcome, 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
pending: 'secondary',
sent: 'secondary',
delivered: 'success',
// Chips mark exceptions (convention 5): a confirmed arrival, a plain handoff
// and a manual mark read as muted text; only the outcomes that need a second
// look get a chip. Green is never chrome here.
const outcomeVariant: Partial<Record<DeliveryOutcome, 'warning' | 'destructive'>> = {
delayed: 'warning',
complained: 'warning',
bounced: 'destructive',
failed: 'destructive',
suppressed: 'destructive',
marked_sent: 'outline',
}
export function InvoiceDeliveryHistory({
@@ -86,201 +82,179 @@ export function InvoiceDeliveryHistory({
})
return (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
{t('delivery_history_title')}
</CardTitle>
<CardDescription>{t('delivery_history_description')}</CardDescription>
</CardHeader>
<CardContent>
{deliveries.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground">{t('delivery_history_legacy_title')}</p>
<p className="mt-1">{t('delivery_history_legacy_description')}</p>
</div>
) : (
<div className="space-y-3">
{deliveries.map((delivery) => {
const occurredAt = delivery.sent_at || delivery.failed_at || delivery.created_at
const isManual = delivery.channel === 'manual'
const outcome = outcomeOf(delivery)
const isEmailSend = !isManual && delivery.status === 'sent'
const recipientCount =
delivery.to_addresses.length + delivery.cc_addresses.length
const recipientStatuses = delivery.provider_recipient_statuses ?? {}
const hasRecipientStatuses = Object.keys(recipientStatuses).length > 0
const recipientRows = [
...delivery.to_addresses.map((address, index) => ({
address,
label: t('delivery_to_label'),
reference: `to:${index + 1}`,
outcome: recipientStatuses[`to:${index + 1}`],
})),
...delivery.cc_addresses.map((address, index) => ({
address,
label: t('delivery_cc_label'),
reference: `cc:${index + 1}`,
outcome: recipientStatuses[`cc:${index + 1}`],
})),
]
<DetailSection
kicker={t('delivery_history_title')}
help={<HelpPopover>{t('delivery_history_description')}</HelpPopover>}
>
{deliveries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('delivery_history_legacy_description')}</p>
) : (
<div className="divide-y divide-border">
{deliveries.map((delivery) => {
const occurredAt = delivery.sent_at || delivery.failed_at || delivery.created_at
const isManual = delivery.channel === 'manual'
const outcome = outcomeOf(delivery)
const outcomeChip = outcomeVariant[outcome]
const isEmailSend = !isManual && delivery.status === 'sent'
const recipientCount =
delivery.to_addresses.length + delivery.cc_addresses.length
const recipientStatuses = delivery.provider_recipient_statuses ?? {}
const hasRecipientStatuses = Object.keys(recipientStatuses).length > 0
const recipientRows = [
...delivery.to_addresses.map((address, index) => ({
address,
label: t('delivery_to_label'),
reference: `to:${index + 1}`,
outcome: recipientStatuses[`to:${index + 1}`],
})),
...delivery.cc_addresses.map((address, index) => ({
address,
label: t('delivery_cc_label'),
reference: `cc:${index + 1}`,
outcome: recipientStatuses[`cc:${index + 1}`],
})),
]
return (
<details key={delivery.id} className="group rounded-lg border bg-card">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-3 px-4 py-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&::-webkit-details-marker]:hidden">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
{isManual ? <Send className="h-4 w-4" /> : <Mail className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{isManual ? t('delivery_channel_manual') : t('delivery_channel_email')}
</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{formatTimestamp(occurredAt)}
</span>
</span>
<Badge variant={outcomeVariant[outcome]}>
{t(`delivery_status_${outcome}`)}
</Badge>
</summary>
return (
<details key={delivery.id} className="group">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-3 py-2 text-sm transition-colors duration-150 hover:bg-secondary/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-150 group-open:rotate-90" />
<span className="tabular-nums text-muted-foreground">{formatTimestamp(occurredAt)}</span>
<span className="min-w-0 flex-1 truncate">
{isManual ? t('delivery_channel_manual') : t('delivery_channel_email')}
</span>
{outcomeChip ? (
<Badge variant={outcomeChip}>{t(`delivery_status_${outcome}`)}</Badge>
) : (
<span className="text-xs text-muted-foreground">{t(`delivery_status_${outcome}`)}</span>
)}
</summary>
<div className="px-4 pb-4">
<Separator className="mb-4" />
{isManual ? (
<p className="text-sm text-muted-foreground">
{t('delivery_manual_unknown_details')}
</p>
) : (
<div className="space-y-4 text-sm">
<dl className="grid gap-3 sm:grid-cols-[8rem_minmax(0,1fr)]">
<dt className="text-muted-foreground">{t('delivery_to_label')}</dt>
<dd className="break-words">{delivery.to_addresses.join(', ')}</dd>
{delivery.cc_addresses.length > 0 && (
<>
<dt className="text-muted-foreground">{t('delivery_cc_label')}</dt>
<dd className="break-words">{delivery.cc_addresses.join(', ')}</dd>
</>
)}
</dl>
{isEmailSend && (
<div className="rounded-lg border bg-muted/30 p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{t('delivery_provider_status_label')}
</span>
<div className="pb-4 pl-6 pt-1">
{isManual ? (
<p className="text-sm text-muted-foreground">
{t('delivery_manual_unknown_details')}
</p>
) : (
<dl className="grid gap-x-6 gap-y-1 text-sm sm:grid-cols-[8rem_minmax(0,1fr)]">
<dt className="text-muted-foreground">{t('delivery_to_label')}</dt>
<dd className="break-words">{delivery.to_addresses.join(', ')}</dd>
{delivery.cc_addresses.length > 0 && (
<>
<dt className="text-muted-foreground">{t('delivery_cc_label')}</dt>
<dd className="break-words">{delivery.cc_addresses.join(', ')}</dd>
</>
)}
{isEmailSend && (
<>
<dt className="text-muted-foreground">{t('delivery_provider_status_label')}</dt>
<dd>
<span className="flex flex-wrap items-center gap-2">
<span>{t(`delivery_status_${outcome}`)}</span>
{delivery.provider_status_at && (
<span className="text-xs text-muted-foreground tabular-nums">
{formatTimestamp(delivery.provider_status_at)}
</span>
)}
</div>
</span>
<p className="mt-1 text-xs text-muted-foreground">
{hasRecipientStatuses
? t('delivery_recipient_statuses_summary')
: t(`delivery_status_explanation_${outcome}`)}
</p>
{hasRecipientStatuses && (
<div className="mt-3 border-t pt-3">
<p className="mb-2 text-xs font-medium">
{t('delivery_recipient_statuses_label')}
</p>
<ul className="space-y-2">
{recipientRows.map((recipient) => (
<li
key={recipient.reference}
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1"
>
<span className="min-w-0 break-all text-xs">
<span className="text-muted-foreground">
{recipient.label}:
</span>{' '}
{recipient.address}
</span>
{recipient.outcome ? (
<span className="flex items-center gap-2">
{recipient.outcome.status === 'delivered' ? (
<span className="text-xs text-muted-foreground">
{t('delivery_status_delivered')}
</span>
) : (
<Badge variant={outcomeVariant[recipient.outcome.status]}>
{t(`delivery_status_${recipient.outcome.status}`)}
</Badge>
)}
<span className="text-xs text-muted-foreground tabular-nums">
{formatTimestamp(recipient.outcome.status_at)}
</span>
</span>
) : (
<span className="text-xs text-muted-foreground">
{t('delivery_recipient_status_unknown')}
</span>
)}
</li>
))}
</ul>
</div>
)}
{delivery.provider_status_detail && (
<p className="mt-2 break-words text-xs text-muted-foreground">
<p className="mt-1 break-words text-xs text-muted-foreground">
{t('delivery_provider_reason_label')}: {delivery.provider_status_detail}
</p>
)}
{recipientCount > 1
&& delivery.provider_status
&& !hasRecipientStatuses && (
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-1 text-xs text-muted-foreground">
{t('delivery_status_whole_send_note')}
</p>
)}
</div>
)}
{delivery.document_attachment_id && (
<Button asChild variant="outline" size="sm" className="max-w-full">
</dd>
</>
)}
{isEmailSend && hasRecipientStatuses && (
<>
<dt className="text-muted-foreground">{t('delivery_recipient_statuses_label')}</dt>
<dd>
<ul className="space-y-1">
{recipientRows.map((recipient) => (
<li
key={recipient.reference}
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs"
>
<span className="min-w-0 break-all">
<span className="text-muted-foreground">{recipient.label}:</span>{' '}
{recipient.address}
</span>
{recipient.outcome ? (
<span className="flex items-center gap-2">
{outcomeVariant[recipient.outcome.status] ? (
<Badge variant={outcomeVariant[recipient.outcome.status]}>
{t(`delivery_status_${recipient.outcome.status}`)}
</Badge>
) : (
<span className="text-muted-foreground">
{t(`delivery_status_${recipient.outcome.status}`)}
</span>
)}
<span className="text-muted-foreground tabular-nums">
{formatTimestamp(recipient.outcome.status_at)}
</span>
</span>
) : (
<span className="text-muted-foreground">
{t('delivery_recipient_status_unknown')}
</span>
)}
</li>
))}
</ul>
</dd>
</>
)}
{delivery.document_attachment_id && (
<>
<dt className="text-muted-foreground">{t('delivery_pdf_label')}</dt>
<dd>
<a
href={`/api/documents/${delivery.document_attachment_id}/inline`}
target="_blank"
rel="noreferrer"
className="inline-flex max-w-full items-center gap-1 hover:underline"
>
<ExternalLink className="mr-2 h-4 w-4" />
<span className="truncate">
{t('delivery_open_pdf', {
filename: delivery.attachment_filename || t('delivery_pdf_fallback'),
})}
{delivery.attachment_filename || t('delivery_pdf_fallback')}
</span>
<ExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
</a>
</Button>
)}
{(delivery.provider || delivery.error_code) && (
<dl className="grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-[8rem_minmax(0,1fr)]">
{delivery.provider && (
<>
<dt>{t('delivery_provider_label')}</dt>
<dd>{delivery.provider}</dd>
</>
)}
{delivery.error_code && (
<>
<dt>{t('delivery_error_label')}</dt>
<dd>{delivery.error_code}</dd>
</>
)}
</dl>
)}
</div>
)}
</div>
</details>
)
})}
</div>
)}
</CardContent>
</Card>
</dd>
</>
)}
{delivery.provider && (
<>
<dt className="text-muted-foreground">{t('delivery_provider_label')}</dt>
<dd className="text-muted-foreground">{delivery.provider}</dd>
</>
)}
{delivery.error_code && (
<>
<dt className="text-muted-foreground">{t('delivery_error_label')}</dt>
<dd className="text-muted-foreground">{delivery.error_code}</dd>
</>
)}
</dl>
)}
</div>
</details>
)
})}
</div>
)}
</DetailSection>
)
}
+283 -306
View File
@@ -11,15 +11,15 @@ import {
Link2Off,
Loader2,
Lock,
PlugZap,
Send,
ShieldAlert,
Unlock,
} from 'lucide-react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { useCapability } from '@/contexts/CompanyContext'
@@ -30,6 +30,7 @@ import {
type AgiSubmissionState,
} from '@/lib/salary/agi-submission-state'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatDateTime } from '@/lib/utils'
interface AGIPanelProps {
salaryRunId: string
@@ -862,55 +863,40 @@ export function AGIPanel(props: AGIPanelProps) {
if (extensionDisabled) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<div className="flex items-start gap-2">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
<p>
{t('disabled_before')}
<code className="mx-1 rounded-sm bg-muted px-1 py-0.5 text-xs">SKATTEVERKET_ENABLED</code>
{t('disabled_after')}
</p>
</div>
</CardContent>
</Card>
<DetailSection kicker={t('title')}>
<p className="text-sm text-muted-foreground">
{t('disabled_before')}
<code className="mx-1 rounded-sm bg-muted px-1 py-0.5 text-xs">SKATTEVERKET_ENABLED</code>
{t('disabled_after')}
</p>
</DetailSection>
)
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<Loader2 className="mr-2 inline h-4 w-4 animate-spin" /> {t('loading_status')}
</CardContent>
</Card>
<DetailSection kicker={t('title')}>
<div role="status" aria-label={t('loading_status')} className="space-y-2">
<Skeleton className="h-4 w-64" />
<Skeleton className="h-4 w-48" />
</div>
</DetailSection>
)
}
if (!status?.connected) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
{t('connect_description')}
</p>
{!readOnly && (
<DetailSection kicker={t('title')}>
<p className="text-sm text-muted-foreground">{t('connect_description')}</p>
{!readOnly && (
<div className="mt-3 flex justify-end">
<Button onClick={handleConnect} disabled={connecting}>
<Link2 className="mr-2 h-4 w-4" />
{connecting ? t('connect_waiting') : t('connect_button')}
</Button>
)}
</CardContent>
</Card>
</div>
)}
</DetailSection>
)
}
@@ -930,6 +916,19 @@ export function AGIPanel(props: AGIPanelProps) {
const missingKvittensScope =
grantedScopes !== null && !missingAgdScope && !grantedScopes.includes('agdredovisningperiod')
// Expired session: the token row exists (so status.connected is true) but
// the access token is past expiry and either has no refresh token or has
// burned through its 10-refresh budget. The only fix is a fresh BankID
// round-trip.
const sessionExpiredStatus = status?.expired === true || status?.canRefresh === false
// One attention sentence per section (convention 6). The expired session
// outranks the missing inlämning scope: nothing can be filed until the
// user has reconnected, and the fresh grant re-evaluates the scope. When
// both hold, the scope notice drops to a muted line so it is still said.
const attn: 'expired' | 'scope' | null =
readOnly ? null : sessionExpiredStatus ? 'expired' : missingAgdScope ? 'scope' : null
// Recovery states expose the advanced actions on their own: the stale-draft
// and error-report guidance below reference them by name.
const forcedAdvanced = draftIsStale || underlagRejected
@@ -937,7 +936,7 @@ export function AGIPanel(props: AGIPanelProps) {
const signedAtRaw =
runSubmission?.signeradTid ?? runSubmission?.submittedAt ?? agiSubmittedAt ?? null
const signedAtText = signedAtRaw ? new Date(signedAtRaw).toLocaleString('sv-SE') : null
const signedAtText = signedAtRaw ? formatDateTime(signedAtRaw) : null
// A signed record without Skatteverket's signeradTid carries our
// reconciliation-time stamp instead (an upper bound on the signing moment,
// see agi-kvittens-reconcile.ts): say so rather than presenting it as the
@@ -956,151 +955,125 @@ export function AGIPanel(props: AGIPanelProps) {
return 'upcoming'
}
const reconnectLabel = connecting ? t('connect_waiting') : t('reconnect_button')
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between text-base">
<span>{t('title')}</span>
<span className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
{t('connected')}
</span>
{!readOnly && (
<button
type="button"
onClick={handleDisconnect}
disabled={actionLoading === 'disconnect' || connecting}
className="inline-flex items-center gap-1 rounded-full border border-border px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground transition-colors hover:border-destructive/50 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
title={t('disconnect_title')}
>
{actionLoading === 'disconnect' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<PlugZap className="h-3 w-3" />
)}
{t('disconnect_button')}
</button>
)}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Filed: the terminal state deserves more than a gray status row.
Kvittensnummer + signature metadata come from the run-scoped
record, which /agi/status serves from the in-flight cache or,
once the kvittens reconciliation has deleted that cache, from
agi_declarations (#1597). A run stamped only via agi_submitted_at
(an original whose receipt a later correction has replaced) still
gets the card, just without a number: better than showing another
declaration's. */}
<DetailSection
kicker={t('title')}
help={<HelpPopover>{t('granskningsunderlag_gloss')}</HelpPopover>}
aside={
// Connected is the normal state: muted text, no chip (convention 5).
// Disconnect is the quiet action beside it.
<span className="flex items-center gap-3 text-[11px] text-muted-foreground">
<span>{t('connected')}</span>
{!readOnly && (
<button
type="button"
onClick={handleDisconnect}
disabled={actionLoading === 'disconnect' || connecting}
className="inline-flex items-center gap-1 underline decoration-border underline-offset-4 transition-colors duration-150 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
title={t('disconnect_title')}
>
{actionLoading === 'disconnect' && <Loader2 className="h-3 w-3 animate-spin" />}
{t('disconnect_button')}
</button>
)}
</span>
}
>
<div className="space-y-4">
{/* Filed: the terminal state, stated first. Kvittensnummer + signature
metadata come from the run-scoped record, which /agi/status serves
from the in-flight cache or, once the kvittens reconciliation has
deleted that cache, from agi_declarations (#1597). A run stamped
only via agi_submitted_at (an original whose receipt a later
correction has replaced) still gets the lines, just without a
number: better than showing another declaration's. */}
{isSigned && (
<div className="rounded-lg border border-border bg-muted/30 p-4">
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-success" />
<div className="space-y-1">
<p className="text-sm font-medium">
{t('success_card_title', { period: prettyPeriod })}
</p>
{runSubmission?.kvittensnummer && (
<p className="text-sm text-muted-foreground tabular-nums">
{t('success_card_kvittens', { kvittens: runSubmission.kvittensnummer })}
</p>
)}
{(runSubmission?.signeradAv || signedAtText) && (
<p className="text-sm text-muted-foreground">
{runSubmission?.signeradAv
? signedAtText
? t(
signedAtEstimated
? 'success_card_signed_by_at_estimated'
: 'success_card_signed_by_at',
{ name: runSubmission.signeradAv, date: signedAtText },
)
: t('success_card_signed_by', { name: runSubmission.signeradAv })
: t(
signedAtEstimated
? 'success_card_signed_at_estimated'
: 'success_card_signed_at',
{ date: signedAtText ?? '' },
)}
</p>
)}
</div>
</div>
<div className="space-y-1 text-sm">
<p className="font-medium">{t('success_card_title', { period: prettyPeriod })}</p>
{runSubmission?.kvittensnummer && (
<p className="text-muted-foreground tabular-nums">
{t('success_card_kvittens', { kvittens: runSubmission.kvittensnummer })}
</p>
)}
{(runSubmission?.signeradAv || signedAtText) && (
<p className="text-muted-foreground">
{runSubmission?.signeradAv
? signedAtText
? t(
signedAtEstimated
? 'success_card_signed_by_at_estimated'
: 'success_card_signed_by_at',
{ name: runSubmission.signeradAv, date: signedAtText },
)
: t('success_card_signed_by', { name: runSubmission.signeradAv })
: t(
signedAtEstimated
? 'success_card_signed_at_estimated'
: 'success_card_signed_at',
{ date: signedAtText ?? '' },
)}
</p>
)}
</div>
)}
{/* Expired-session banner: the token row exists (so status.connected
is true) but the access token is past expiry and either has no
refresh token or has burned through its 10-refresh budget. The
only fix is a fresh BankID round-trip. */}
{(status?.expired === true || status?.canRefresh === false) && !readOnly && (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">{t('expired_banner_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('expired_banner_description')}
</p>
<Button size="sm" variant="outline" className="mt-2" onClick={handleConnect} disabled={connecting}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
{connecting ? t('connect_waiting') : t('reconnect_button')}
</Button>
</div>
{attn === 'expired' && (
<AttnLine
action={{
label: reconnectLabel,
onClick: () => {
if (!connecting) handleConnect()
},
}}
>
{t('expired_banner_title')}. {t('expired_banner_description')}
</AttnLine>
)}
{/* Missing-scope nudges: proactive, before the user hits a 403
invalid_scope. The agd scope was added after some users had
already connected, so their stored token grants moms/skattekonto
but not AGI: that one stays a hard nudge. The kvittens scope only
breaks the final receipt fetch and may not be grantable yet, so
its notice is softer and dismissible. */}
{missingAgdScope && !readOnly && (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">
{t('missing_scope_title')}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('missing_scope_description')}
</p>
<a
href="/settings/tax"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
{t('open_settings')} <ExternalLink className="h-3.5 w-3.5" />
but not AGI: that one is the hard nudge (the attention line, or a
muted line when the expired session already holds it). The
kvittens scope only breaks the final receipt fetch and may not be
grantable yet, so its notice is softer and dismissible. */}
{attn === 'scope' && (
<AttnLine action={{ label: t('open_settings'), href: '/settings/tax' }}>
{t('missing_scope_title')}. {t('missing_scope_description')}
</AttnLine>
)}
{missingAgdScope && !readOnly && attn !== 'scope' && (
<p className="text-xs text-muted-foreground">
{t('missing_scope_title')}. {t('missing_scope_description')}{' '}
<a href="/settings/tax" className="underline underline-offset-2 hover:text-foreground">
{t('open_settings')}
</a>
</div>
</p>
)}
{missingKvittensScope && !kvittensNoticeDismissed && !readOnly && (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">
{t('kvittens_scope_title')}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('kvittens_scope_description')}
</p>
<div className="mt-2 flex items-center gap-4">
<a
href="/settings/tax"
className="inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
{t('open_settings')} <ExternalLink className="h-3.5 w-3.5" />
</a>
<button
type="button"
onClick={dismissKvittensNotice}
className="text-sm text-muted-foreground hover:underline"
>
{t('kvittens_scope_dismiss')}
</button>
</div>
</div>
<p className="text-xs text-muted-foreground">
<span className="text-foreground">{t('kvittens_scope_title')}.</span>{' '}
{t('kvittens_scope_description')}{' '}
<a href="/settings/tax" className="underline underline-offset-2 hover:text-foreground">
{t('open_settings')}
</a>{' '}
<button
type="button"
onClick={dismissKvittensNotice}
className="underline underline-offset-2 hover:text-foreground"
>
{t('kvittens_scope_dismiss')}
</button>
</p>
)}
{/* Status summary */}
<div className="space-y-1.5 text-sm">
{/* Status summary: one line per step, hairlines between. */}
<div className="divide-y divide-border text-sm">
<StatusRow
ok={!!agiGeneratedAt}
okText={agiGeneratedAt ? t('file_generated', { date: new Date(agiGeneratedAt).toLocaleString('sv-SE') }) : ''}
okText={agiGeneratedAt ? t('file_generated', { date: formatDateTime(agiGeneratedAt) }) : ''}
pendingText={t('file_not_generated')}
/>
<StatusRow
@@ -1109,7 +1082,7 @@ export function AGIPanel(props: AGIPanelProps) {
runSubmission?.kvittensnummer
? t('submitted_with_kvittens', { kvittens: runSubmission.kvittensnummer })
: agiSubmittedAt
? t('submitted_at', { date: new Date(agiSubmittedAt).toLocaleString('sv-SE') })
? t('submitted_at', { date: formatDateTime(agiSubmittedAt) })
: t('submitted')
}
pendingText={
@@ -1130,20 +1103,14 @@ export function AGIPanel(props: AGIPanelProps) {
treatment so the user understands they must fix errors before
BankID signing is even possible. */}
{submission?.signeringslank && awaitingSigning && !draftIsStale && (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">
<InfoTooltip variant="help" content={t('granskningsunderlag_gloss')}>
{t('draft_locked_title')}
</InfoTooltip>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t('draft_locked_description')}
</p>
<div className="text-sm">
<p className="font-medium">{t('draft_locked_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('draft_locked_description')}</p>
<a
href={submission.signeringslank}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium hover:underline"
className="mt-2 inline-flex items-center gap-1 font-medium underline underline-offset-2 hover:opacity-80"
>
{t('open_signing_link')} <ExternalLink className="h-3.5 w-3.5" />
</a>
@@ -1156,13 +1123,13 @@ export function AGIPanel(props: AGIPanelProps) {
it would file the old amounts. The "Lås upp" button below releases
the SKV lock; the user then re-submits the freshly generated XML. */}
{awaitingSigning && draftIsStale && (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">{t('stale_draft_title')}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<div className="text-sm">
<p className="font-medium">{t('stale_draft_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('stale_draft_description', {
generatedAt: agiGeneratedAt ? new Date(agiGeneratedAt).toLocaleString('sv-SE') : '',
generatedAt: agiGeneratedAt ? formatDateTime(agiGeneratedAt) : '',
draftCreatedAt: submission?.updatedAt
? ` (${new Date(submission.updatedAt).toLocaleString('sv-SE')})`
? ` (${formatDateTime(submission.updatedAt)})`
: '',
})}{' '}
{t('stale_draft_click')}{' '}
@@ -1180,32 +1147,34 @@ export function AGIPanel(props: AGIPanelProps) {
link would be permanently unreachable even though the extension
persisted it. */}
{submission?.signeringslank && underlagRejected && (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-medium text-destructive">
{t('incorrect_data_title')}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<div className="text-sm">
<p className="font-medium text-destructive">{t('incorrect_data_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{submission.meddelande || t('incorrect_data_description')}
</p>
<a
href={submission.signeringslank}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-destructive hover:underline"
className="mt-2 inline-flex items-center gap-1 font-medium text-destructive underline underline-offset-2 hover:opacity-80"
>
{t('open_error_report')} <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{/* Skatteverket's kontrollresultat findings: one line per finding,
severity carried by the text tone (terracotta for STOPP, ochre
for ärende/warning), no box. */}
{kontroller.length > 0 && (
<div className="space-y-1 rounded-lg border bg-muted/30 p-3">
<div className="divide-y divide-border text-xs">
{kontroller.map((k, i) => (
<div
key={i}
className={`flex items-start gap-2 text-xs ${
k.status === 'STOPP' ? 'text-destructive' : 'text-warning'
}`}
className={cn(
'flex items-start gap-2 py-2',
k.status === 'STOPP' ? 'text-destructive' : 'text-attn',
)}
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
@@ -1225,17 +1194,18 @@ export function AGIPanel(props: AGIPanelProps) {
// don't have to hunt for it in settings.
const sessionExpired =
/sessionen har gått ut|logga in med bankid igen/i.test(error) ||
status?.expired === true ||
status?.canRefresh === false
sessionExpiredStatus
return (
<div className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
{error}
<div className="text-sm text-destructive">
<p>
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
{error}
</p>
{sessionExpired && !readOnly && (
<div className="mt-2">
<Button size="sm" variant="outline" onClick={handleConnect} disabled={connecting}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
{connecting ? t('connect_waiting') : t('reconnect_button')}
<Link2 className="mr-2 h-3.5 w-3.5" />
{reconnectLabel}
</Button>
</div>
)}
@@ -1243,56 +1213,23 @@ export function AGIPanel(props: AGIPanelProps) {
)
})()}
{success && !error && (
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm">
<p className="text-sm">
<CheckCircle2 className="mr-1 inline h-3.5 w-3.5 text-success" />
{success}
</div>
</p>
)}
{!readOnly && !isSigned && (
<div className="space-y-3">
{/* Primary path: one click runs the whole filing chain. Hidden
while a signing draft is open at SKV (the period is locked,
so a resubmission would be refused): the signing-link card
above is the CTA then, and the stale-draft recovery goes
through the advanced actions per the guidance text. The
XML download stays free for manual filing regardless. */}
<div className="flex flex-wrap items-center gap-2">
{!awaitingSigning && (
<Button
onClick={handleSubmitChain}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'chain' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{t('chain_button')}
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={handleDownloadXml}
disabled={actionLoading === 'download'}
title={t('download_xml_title')}
>
{actionLoading === 'download' ? (
<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('download_xml_button')}
</Button>
</div>
{/* Live progress of the one-click chain: a flat step list that
sits with the status above, so the action row does not move
while it runs. */}
{chain && (
<ol className="space-y-1.5 rounded-lg border border-border bg-muted/30 p-3">
<ol className="space-y-1 text-xs">
{CHAIN_STEPS.map(step => {
const state = chainStepState(step)
return (
<li key={step} className="flex items-center gap-2 text-xs">
<li key={step} className="flex items-center gap-2">
{state === 'done' ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : state === 'running' ? (
@@ -1311,87 +1248,127 @@ export function AGIPanel(props: AGIPanelProps) {
</ol>
)}
{/* The one action row. Primary path: one click runs the whole
filing chain. Hidden while a signing draft is open at SKV (the
period is locked, so a resubmission would be refused): the
signing link above is the CTA then, and the stale-draft
recovery goes through the advanced actions per the guidance
text. The XML download stays free for manual filing
regardless. The advanced toggle sits quietly at the left of
the row; its actions fold out beneath. */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
{!forcedAdvanced && (
<button
type="button"
onClick={() => setShowAdvanced(v => !v)}
className="text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
{advancedOpen ? t('advanced_hide') : t('advanced_show')}
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={handleDownloadXml}
disabled={actionLoading === 'download'}
title={t('download_xml_title')}
>
{actionLoading === 'download' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-2 h-3.5 w-3.5" />
)}
{t('download_xml_button')}
</Button>
{!awaitingSigning && (
<Button
onClick={handleSubmitChain}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'chain' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{t('chain_button')}
</Button>
)}
</div>
</div>
{/* Recovery/expert actions: each is one step of the chain above,
for resuming after a partial failure. Auto-expanded when a
recovery state (stale draft, rejected underlag) references
them by name. */}
<div>
{!forcedAdvanced && (
<button
type="button"
onClick={() => setShowAdvanced(v => !v)}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
{advancedOpen && (
<div className="flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled={actionLoading !== null || !hasSkatteverket}
>
{advancedOpen ? t('advanced_hide') : t('advanced_show')}
</button>
)}
{advancedOpen && (
<div className="mt-2 flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'submit' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
{t('submit_button')}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCreateSigningLink}
disabled={actionLoading !== null || !underlagSubmitted}
>
{actionLoading === 'granskning' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-1.5 h-3.5 w-3.5" />
)}
{t('signing_link_button')}
</Button>
{actionLoading === 'submit' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-2 h-3.5 w-3.5" />
)}
{t('submit_button')}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCreateSigningLink}
disabled={actionLoading !== null || !underlagSubmitted}
>
{actionLoading === 'granskning' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-2 h-3.5 w-3.5" />
)}
{t('signing_link_button')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
disabled={actionLoading !== null}
>
{actionLoading === 'check' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-2 h-3.5 w-3.5" />
)}
{t('check_kvittens_button')}
</Button>
{awaitingSigning && (
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
onClick={handleUnlock}
disabled={actionLoading !== null}
>
{actionLoading === 'check' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
{actionLoading === 'unlock' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
<Unlock className="mr-2 h-3.5 w-3.5" />
)}
{t('check_kvittens_button')}
{t('unlock_button')}
</Button>
{awaitingSigning && (
<Button
size="sm"
variant="ghost"
onClick={handleUnlock}
disabled={actionLoading !== null}
>
{actionLoading === 'unlock' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Unlock className="mr-1.5 h-3.5 w-3.5" />
)}
{t('unlock_button')}
</Button>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{!readOnly && !isSigned && !hasSkatteverket && (
<UpgradeNote>{t('upgrade_note')}</UpgradeNote>
)}
</CardContent>
</Card>
</div>
</DetailSection>
)
}
@@ -1405,11 +1382,11 @@ function StatusRow({
pendingText: string
}) {
return (
<div className="flex items-center gap-2">
<div className="flex items-start gap-2 py-2">
{ok ? (
<CheckCircle2 className="h-4 w-4 text-success" />
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-success" />
) : (
<Link2Off className="h-4 w-4 text-muted-foreground" />
<Link2Off className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="text-muted-foreground">{ok ? okText : pendingText}</span>
</div>
+98 -68
View File
@@ -2,13 +2,22 @@
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Plus, Trash2, Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { Skeleton } from '@/components/ui/skeleton'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -37,6 +46,12 @@ const BENEFIT_LABELS: Record<BenefitType, string> = {
other: 'Övrig förmån',
}
// In-row text action: underlined so it reads as an action next to plain
// values, the hairline underline darkening on hover (same idiom as the
// invoice detail rows).
const ROW_ACTION_CLASS =
'text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors duration-150 hover:text-foreground hover:decoration-foreground disabled:opacity-50'
export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) {
const t = useTranslations('salary_employee')
const { toast } = useToast()
@@ -128,62 +143,77 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
})()
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">{t('benefits_title')}</CardTitle>
{canWrite && !adding && (
<Button type="button" size="sm" variant="outline" onClick={() => setAdding(true)}>
<Plus className="mr-2 h-4 w-4" />
<DetailSection
kicker={t('benefits_title')}
help={<HelpPopover>{t('benefits_help')}</HelpPopover>}
aside={
canWrite ? (
<Button type="button" size="sm" variant="outline" className="-my-1" onClick={() => setAdding(true)}>
{t('benefits_add')}
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<p className="text-sm text-muted-foreground">{t('benefits_loading')}</p>
) : benefits.length === 0 && !adding ? (
<p className="text-sm text-muted-foreground">
{t('benefits_empty')}
</p>
) : (
benefits.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('benefits_type')}</TableHead>
<TableHead>{t('benefits_description')}</TableHead>
<TableHead className="text-right">{t('benefits_value_per_month')}</TableHead>
<TableHead>{t('benefits_period')}</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{benefits.map(b => (
<TableRow key={b.id}>
<TableCell>{t(`benefits_type_${b.benefit_type}`)}</TableCell>
<TableCell className="text-muted-foreground">{b.description}</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(b.monthly_value)}</TableCell>
<TableCell className="text-muted-foreground tabular-nums text-xs">
{formatDate(b.valid_from)}
{b.valid_to ? ` ${formatDate(b.valid_to)}` : ` ${t('benefits_ongoing')}`}
</TableCell>
<TableCell className="text-right">
{canWrite && (
<Button type="button" size="icon" variant="ghost" onClick={() => handleDelete(b.id)} aria-label={t('benefits_remove')}>
<Trash2 className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
)}
) : undefined
}
>
{loading ? (
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
) : benefits.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('benefits_empty')}</p>
) : (
<ul className="divide-y divide-border text-sm">
{benefits.map((b) => {
const typeLabel = t(`benefits_type_${b.benefit_type}`)
// The description defaults to the Swedish type label when left
// empty on creation; repeating it next to the type says nothing.
const showDescription =
!!b.description &&
b.description !== typeLabel &&
b.description !== BENEFIT_LABELS[b.benefit_type]
return (
<li key={b.id} className="flex min-h-10 flex-wrap items-center gap-x-3 gap-y-1 py-2">
<span className="min-w-0 truncate">
{typeLabel}
{showDescription && (
<span className="text-muted-foreground">{' · '}{b.description}</span>
)}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
{formatDate(b.valid_from)} → {b.valid_to ? formatDate(b.valid_to) : t('benefits_ongoing')}
</span>
<span className="ml-auto tabular-nums">
{formatCurrency(b.monthly_value)}
<span className="text-muted-foreground">{t('benefits_per_month')}</span>
</span>
{canWrite && (
<button type="button" onClick={() => handleDelete(b.id)} className={ROW_ACTION_CLASS}>
{t('benefits_remove')}
</button>
)}
</li>
)
})}
</ul>
)}
{adding && (
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Add dialog (convention 13: centered modal for create). Closing by
Escape or backdrop is the same as Avbryt; both are held while a
save is in flight. */}
<Dialog
open={adding}
onOpenChange={(open) => {
if (!open && !submitting) reset()
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('benefits_add')}</DialogTitle>
<DialogDescription>{t('benefits_help')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="benefit_type">{t('benefits_type')}</Label>
<Select value={type} onValueChange={(v) => setType(v as BenefitType)}>
@@ -245,7 +275,7 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="valid_from">{t('benefits_valid_from')}</Label>
<Input id="valid_from" type="date" value={validFrom} onChange={e => setValidFrom(e.target.value)} />
@@ -255,17 +285,17 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
<Input id="valid_to" type="date" value={validTo} onChange={e => setValidTo(e.target.value)} />
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={reset} disabled={submitting}>{t('form_cancel')}</Button>
<Button type="button" size="sm" onClick={handleAdd} disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('form_save')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
<DialogFooter>
<Button type="button" variant="outline" onClick={reset} disabled={submitting}>{t('form_cancel')}</Button>
<Button type="button" onClick={handleAdd} disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('form_save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DetailSection>
)
}
+156 -138
View File
@@ -1,7 +1,7 @@
'use client'
/**
* Ingående saldon (payroll cutover) panel on the employee editor.
* Ingående saldon (payroll cutover) section on the employee detail page.
*
* Mid-year switchers from another payroll system enter per-employee state
* here: YTD accumulators, vacation balances (incl. sparade dagar per
@@ -12,12 +12,12 @@
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { formatDate } from '@/lib/utils'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { Skeleton } from '@/components/ui/skeleton'
import { Save } from 'lucide-react'
import { SettingsGroup, SettingsInput, SettingsRow } from '@/components/settings/SettingsRows'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -40,6 +40,9 @@ const currentYear = new Date().getFullYear()
/** Sparade dagar origin years: Semesterlagen allows saving max 5 years. */
const SAVED_YEARS = Array.from({ length: 5 }, (_, i) => String(currentYear - 1 - i))
// Numeric and date fields are sized by their content, not by the row.
const FIELD_CLASS = 'max-w-44 flex-none tabular-nums'
interface PanelValues {
cutoverDate: string
ytdGross: string
@@ -72,6 +75,10 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
const [saving, setSaving] = useState(false)
const [locked, setLocked] = useState(false)
const [hasRow, setHasRow] = useState(false)
// Collapsed by default: this is a one-time form for switching payroll
// system, and 14 empty input rows on every employee is clutter. Opens by
// itself once balances exist, so stored values are never hidden.
const [expanded, setExpanded] = useState<boolean | null>(null)
const [cutoverDate, setCutoverDate] = useState(`${currentYear}-01-01`)
const [ytdGross, setYtdGross] = useState('')
@@ -202,151 +209,161 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('opening_balances_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
<DetailSection kicker={t('opening_balances_title')}>
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</DetailSection>
)
}
const readOnly = locked || !canWrite
const open = expanded ?? hasRow
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('opening_balances_title')}</CardTitle>
<p className="text-sm text-muted-foreground">{t('opening_balances_description')}</p>
</CardHeader>
{/* Own form: this panel is its own save scope. It must never be nested
inside another form (invalid HTML, and its submit would leak). */}
<form
onSubmit={(e) => {
e.preventDefault()
handleSave()
}}
/* Own form: this section is its own save scope. It must never be nested
inside another form (invalid HTML, and its submit would leak). The
save button closes the expanded form and is a submit, so Enter in any
field saves too. */
<form
onSubmit={(e) => {
e.preventDefault()
handleSave()
}}
>
<DetailSection
kicker={t('opening_balances_title')}
help={<HelpPopover>{t('opening_balances_description')}</HelpPopover>}
aside={
<Button
type="button"
variant="outline"
size="sm"
className="-my-1"
aria-expanded={open}
onClick={() => setExpanded(!open)}
>
{open ? t('opening_balances_hide') : t('opening_balances_show')}
</Button>
}
>
<CardContent className="space-y-6">
{locked && (
<p className="text-sm text-muted-foreground border border-border rounded-lg p-3">
{t('opening_balances_locked_notice')}
{!open ? (
<p className="text-sm text-muted-foreground">
{hasRow
? t('opening_balances_collapsed_set', { date: formatDate(cutoverDate) })
: t('opening_balances_collapsed_none')}
</p>
) : (
<>
{locked && (
<p className="mb-3 text-sm text-muted-foreground">{t('opening_balances_locked_notice')}</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-cutover">{t('opening_balances_cutover_date')}</Label>
<Input
id="ob-cutover"
type="date"
value={cutoverDate}
onChange={(e) => setCutoverDate(e.target.value)}
disabled={readOnly}
/>
<p className="text-xs text-muted-foreground">{t('opening_balances_cutover_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="ob-karens">{t('opening_balances_karens')}</Label>
<Input
id="ob-karens"
type="number"
min={0}
max={10}
step={1}
value={karens}
onChange={(e) => setKarens(e.target.value)}
disabled={readOnly}
className="tabular-nums"
/>
<p className="text-xs text-muted-foreground">{t('opening_balances_karens_hint')}</p>
</div>
</div>
<SettingsRow
label={t('opening_balances_cutover_date')}
htmlFor="ob-cutover"
help={t('opening_balances_cutover_hint')}
align="baseline"
>
<SettingsInput
id="ob-cutover"
type="date"
value={cutoverDate}
onChange={(e) => setCutoverDate(e.target.value)}
disabled={readOnly}
className={FIELD_CLASS}
/>
</SettingsRow>
<SettingsRow
label={t('opening_balances_karens')}
htmlFor="ob-karens"
help={t('opening_balances_karens_hint')}
align="baseline"
>
<SettingsInput
id="ob-karens"
type="number"
min={0}
max={10}
step={1}
value={karens}
onChange={(e) => setKarens(e.target.value)}
disabled={readOnly}
className={FIELD_CLASS}
/>
</SettingsRow>
<div>
<h2 className="text-sm uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_ytd_heading')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-ytd-gross">{t('opening_balances_ytd_gross')}</Label>
<Input id="ob-ytd-gross" type="number" min={0} value={ytdGross}
onChange={(e) => setYtdGross(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-ytd-tax">{t('opening_balances_ytd_tax')}</Label>
<Input id="ob-ytd-tax" type="number" min={0} value={ytdTax}
onChange={(e) => setYtdTax(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-ytd-net">{t('opening_balances_ytd_net')}</Label>
<Input id="ob-ytd-net" type="number" min={0} value={ytdNet}
onChange={(e) => setYtdNet(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
</div>
</div>
<SettingsGroup label={t('opening_balances_ytd_heading')} className="pt-6">
<SettingsRow label={t('opening_balances_ytd_gross')} htmlFor="ob-ytd-gross" align="baseline">
<SettingsInput id="ob-ytd-gross" type="number" min={0} value={ytdGross}
onChange={(e) => setYtdGross(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
<SettingsRow label={t('opening_balances_ytd_tax')} htmlFor="ob-ytd-tax" align="baseline">
<SettingsInput id="ob-ytd-tax" type="number" min={0} value={ytdTax}
onChange={(e) => setYtdTax(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
<SettingsRow label={t('opening_balances_ytd_net')} htmlFor="ob-ytd-net" align="baseline">
<SettingsInput id="ob-ytd-net" type="number" min={0} value={ytdNet}
onChange={(e) => setYtdNet(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
</SettingsGroup>
<div>
<h2 className="text-sm uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_vacation_heading')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-days-remaining">{t('opening_balances_days_remaining')}</Label>
<Input id="ob-days-remaining" type="number" min={0} max={40} step={0.5} value={daysRemaining}
onChange={(e) => setDaysRemaining(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-days-taken">{t('opening_balances_days_taken')}</Label>
<Input id="ob-days-taken" type="number" min={0} max={40} step={0.5} value={daysTaken}
onChange={(e) => setDaysTaken(e.target.value)} disabled={readOnly} className="tabular-nums" />
<p className="text-xs text-muted-foreground">{t('opening_balances_days_taken_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="ob-liability">{t('opening_balances_liability')}</Label>
<Input id="ob-liability" type="number" min={0} value={liability}
onChange={(e) => setLiability(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-liability-avgifter">{t('opening_balances_liability_avgifter')}</Label>
<Input id="ob-liability-avgifter" type="number" min={0} value={liabilityAvgifter}
onChange={(e) => setLiabilityAvgifter(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
</div>
</div>
<SettingsGroup label={t('opening_balances_vacation_heading')} className="pt-6">
<SettingsRow label={t('opening_balances_days_remaining')} htmlFor="ob-days-remaining" align="baseline">
<SettingsInput id="ob-days-remaining" type="number" min={0} max={40} step={0.5} value={daysRemaining}
onChange={(e) => setDaysRemaining(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
<SettingsRow
label={t('opening_balances_days_taken')}
htmlFor="ob-days-taken"
help={t('opening_balances_days_taken_hint')}
align="baseline"
>
<SettingsInput id="ob-days-taken" type="number" min={0} max={40} step={0.5} value={daysTaken}
onChange={(e) => setDaysTaken(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
<SettingsRow label={t('opening_balances_liability')} htmlFor="ob-liability" align="baseline">
<SettingsInput id="ob-liability" type="number" min={0} value={liability}
onChange={(e) => setLiability(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
<SettingsRow label={t('opening_balances_liability_avgifter')} htmlFor="ob-liability-avgifter" align="baseline">
<SettingsInput id="ob-liability-avgifter" type="number" min={0} value={liabilityAvgifter}
onChange={(e) => setLiabilityAvgifter(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
</SettingsGroup>
<div>
<h2 className="text-sm uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_saved_heading')}
</h2>
<p className="text-xs text-muted-foreground mb-3">{t('opening_balances_saved_hint')}</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{SAVED_YEARS.map((year) => (
<div key={year} className="space-y-2">
<Label htmlFor={`ob-saved-${year}`} className="tabular-nums">{year}</Label>
<Input
id={`ob-saved-${year}`}
type="number"
min={0}
max={40}
step={0.5}
value={savedByYear[year] ?? ''}
onChange={(e) => setSavedByYear((prev) => ({ ...prev, [year]: e.target.value }))}
disabled={readOnly}
className="tabular-nums"
/>
</div>
))}
</div>
</div>
<SettingsGroup
label={t('opening_balances_saved_heading')}
help={t('opening_balances_saved_hint')}
className="pt-6"
>
{SAVED_YEARS.map((year, index) => (
<SettingsRow
key={year}
label={<span className="tabular-nums">{year}</span>}
htmlFor={`ob-saved-${year}`}
align="baseline"
borderless={index === SAVED_YEARS.length - 1}
>
<SettingsInput
id={`ob-saved-${year}`}
type="number"
min={0}
max={40}
step={0.5}
value={savedByYear[year] ?? ''}
onChange={(e) => setSavedByYear((prev) => ({ ...prev, [year]: e.target.value }))}
disabled={readOnly}
className={FIELD_CLASS}
/>
</SettingsRow>
))}
</SettingsGroup>
{!readOnly && (
<div className="flex justify-end">
<Button type="submit" disabled={saving || !dirty}>
<Save className="h-4 w-4 mr-2" />
<div className="mt-4 flex justify-end">
<Button type="submit" size="sm" disabled={saving || !dirty}>
{saving
? t('opening_balances_saving')
: hasRow
@@ -355,8 +372,9 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
</Button>
</div>
)}
</CardContent>
</form>
</Card>
</>
)}
</DetailSection>
</form>
)
}
+114 -113
View File
@@ -3,13 +3,16 @@
import Link from 'next/link'
import { useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { AlertTriangle, Download, Loader2, CheckCircle2, ChevronDown, Info } from 'lucide-react'
import { DetailSection, DefRow } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import { SettingsSelect } from '@/components/settings/SettingsRows'
import { Download, Loader2, ChevronDown } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { downloadFile } from '@/lib/browser/download-file'
import { failureDescription } from '@/lib/browser/action-failure'
import { cn, formatDateTime } from '@/lib/utils'
import type { ErrorLocale } from '@/lib/errors/get-error-message'
type PaymentFormat = 'bg_lb' | 'pain001'
@@ -86,6 +89,14 @@ export function PaymentFilePanel({
? `/api/salary/runs/${salaryRunId}/payment/bg-lb`
: `/api/salary/runs/${salaryRunId}/payment/pain001`
// The sender account lives in company_settings, not in the Bolagsverket
// snapshot shown on the settings overview: users see a bankgiro there and
// reasonably believe it is configured. Say the precondition here, before
// the download 400s on it.
const senderMissing =
(format === 'bg_lb' && senderBankgiro === null) ||
(format === 'pain001' && senderIban === null)
async function handleDownload() {
// The button is disabled while a file is in flight; this guard closes the
// double-click / Enter-repeat race before React has re-rendered it. Two
@@ -124,135 +135,125 @@ export function PaymentFilePanel({
}
}
// The flow looked clean all the way until the bank said no: the file
// downloads fine and only fails at upload, on the pay date. Say the delivery
// precondition of the chosen format up front. It is one sentence per
// format: the pain.001 agreement caveat, or the LB sunset with its link.
const formatCaveat =
format === 'pain001' ? (
t('pain001_agreement_warning')
) : (
<>
{t('sunset_warning')}{' '}
<Link href="/settings/salary" className="underline underline-offset-2 hover:text-foreground">
{t('sunset_link')}
</Link>
</>
)
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DetailSection
kicker={t('title')}
help={
<HelpPopover>
<p>
<span className="font-medium">{FORMAT_LABEL.pain001}</span>: {t('format_description_pain001')}
</p>
<p className="mt-2">
<span className="font-medium">{FORMAT_LABEL.bg_lb}</span>: {t('format_description_bg_lb')}
</p>
<p className="mt-2 text-muted-foreground">{t('open_payments_note')}</p>
</HelpPopover>
}
>
<div>
{paymentFileFormat && paymentFileGeneratedAt && (
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-success" />
<div>
{t('last_generated')}{' '}
<span className="text-foreground">
{FORMAT_LABEL[paymentFileFormat as PaymentFormat] ?? paymentFileFormat}
</span>{' '}
({new Date(paymentFileGeneratedAt).toLocaleString('sv-SE')})
</div>
</div>
<DefRow label={t('last_generated')}>
{FORMAT_LABEL[paymentFileFormat as PaymentFormat] ?? paymentFileFormat}{' '}
<span className="text-muted-foreground tabular-nums">
({formatDateTime(paymentFileGeneratedAt)})
</span>
</DefRow>
)}
{!readOnly && (
<>
<div className="space-y-1">
<label className="text-sm font-medium">{t('format_label')}</label>
<Select value={format} onValueChange={(v) => setFormat(v as PaymentFormat)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pain001">{FORMAT_LABEL.pain001}</SelectItem>
<SelectItem value="bg_lb">{FORMAT_LABEL.bg_lb}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{format === 'bg_lb' ? t('format_description_bg_lb') : t('format_description_pain001')}
</p>
</div>
<DefRow label={t('format_label')}>
<SettingsSelect
aria-label={t('format_label')}
value={format}
onChange={(e) => setFormat(e.target.value as PaymentFormat)}
wrapperClassName="-my-1"
>
<option value="pain001">{FORMAT_LABEL.pain001}</option>
<option value="bg_lb">{FORMAT_LABEL.bg_lb}</option>
</SettingsSelect>
</DefRow>
)}
</div>
{/* The flow looked clean all the way until the bank said no: the
file downloads fine and only fails at upload, on the pay date.
Say the delivery precondition up front instead. */}
{format === 'pain001' && (
<div className="flex items-start gap-2 rounded-lg border border-border p-3 text-xs">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span className="text-muted-foreground">{t('pain001_agreement_warning')}</span>
</div>
)}
{format === 'bg_lb' && (
<div className="flex items-start gap-2 rounded-lg border border-border p-3 text-xs">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span className="text-muted-foreground">
{t('sunset_warning')}{' '}
<Link
href="/settings/salary"
className="underline underline-offset-2 hover:text-foreground"
>
{t('sunset_link')}
</Link>
</span>
</div>
)}
{/* The sender account lives in company_settings, not in the
Bolagsverket snapshot shown on the settings overview: users see
a bankgiro there and reasonably believe it is configured. Say
the precondition here, before the download 400s on it. */}
{((format === 'bg_lb' && senderBankgiro === null) ||
(format === 'pain001' && senderIban === null)) && (
<div className="flex items-start gap-2 rounded-lg border border-border p-3 text-xs">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span className="text-muted-foreground">
{format === 'bg_lb' ? t('missing_bankgiro_warning') : t('missing_iban_warning')}{' '}
<Link
href="/settings/invoicing"
className="underline underline-offset-2 hover:text-foreground"
>
{t('missing_sender_link')}
</Link>
</span>
</div>
)}
<div className="flex justify-end">
<Button onClick={handleDownload} disabled={downloading}>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{t('download')}
</Button>
</div>
{!readOnly && (
<div className="mt-3 space-y-3">
{/* One attention sentence per section (convention 6). A missing
sender account outranks the format caveat: without it the
download fails outright. The caveat then drops to a muted line
so the delivery precondition is still said. */}
{senderMissing ? (
<>
<AttnLine action={{ label: t('missing_sender_link'), href: '/settings/invoicing' }}>
{format === 'bg_lb' ? t('missing_bankgiro_warning') : t('missing_iban_warning')}
</AttnLine>
<p className="text-xs text-muted-foreground">{formatCaveat}</p>
</>
) : format === 'pain001' ? (
<AttnLine>{t('pain001_agreement_warning')}</AttnLine>
) : (
<AttnLine action={{ label: t('sunset_link'), href: '/settings/salary' }}>
{t('sunset_warning')}
</AttnLine>
)}
{/* The one action row: the bank instructions are an appendix that
folds out under it, so the button stays where the section ends. */}
<div className="flex flex-wrap items-center justify-between gap-3">
<button
type="button"
onClick={() => setShowInstructions(s => !s)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowInstructions((s) => !s)}
className="inline-flex items-center gap-2 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
aria-expanded={showInstructions}
>
<ChevronDown className={`h-3 w-3 transition-transform ${showInstructions ? 'rotate-180' : ''}`} />
<ChevronDown
className={cn('h-3 w-3 transition-transform duration-150', showInstructions && 'rotate-180')}
/>
{t('instructions_toggle')}
</button>
<Button onClick={handleDownload} disabled={downloading}>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{t('download')}
</Button>
</div>
{showInstructions && (
<div className="space-y-2 rounded-lg border bg-muted/30 p-3 text-xs">
{showInstructions && (
<div className="text-xs">
<div className="divide-y divide-border">
{sortedBanks.map((bank) => (
<div key={bank}>
<strong className="text-foreground">
<p key={bank} className="py-2">
<span className="text-foreground">
{BANK_NAME[bank]}
{bank === matchedBank ? ` (${t('your_bank')})` : ''}.
</strong>{' '}
<span className="text-muted-foreground">
{t(`steps_${format}_${bank}`)}
</span>
</div>
</span>{' '}
<span className="text-muted-foreground">{t(`steps_${format}_${bank}`)}</span>
</p>
))}
<p className="pt-1 text-muted-foreground border-t mt-2">
{t('instructions_footer')}
</p>
</div>
)}
<div className="flex items-start gap-2 rounded-lg border border-dashed bg-muted/20 p-3 text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>{t('open_payments_note')}</span>
<p className="pt-2 text-muted-foreground">{t('instructions_footer')}</p>
</div>
</>
)}
</CardContent>
</Card>
)}
</div>
)}
</DetailSection>
)
}
+90 -94
View File
@@ -2,13 +2,17 @@
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Settings2, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import {
SettingsInput,
SettingsRow,
SettingsRowNote,
SettingsTextarea,
} from '@/components/settings/SettingsRows'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -27,6 +31,11 @@ interface SalaryOverridePanelProps {
disabled?: boolean
}
// Amount fields are sized by their content, not by the row. ph-no-capture:
// the placeholder is the employee's effective amount, and replay masking
// covers values, not attributes.
const FIELD_CLASS = 'max-w-44 flex-none tabular-nums ph-no-capture'
function num(v: string): number | null {
const trimmed = v.trim()
if (!trimmed) return null
@@ -130,118 +139,105 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
}
}
const fieldDisabled = props.disabled || saving
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<CardTitle className="text-base">{t('title')}</CardTitle>
<DetailSection
kicker={t('title')}
help={<HelpPopover>{t('description')}</HelpPopover>}
aside={
// Chips mark exceptions: the chip appears only once an override is
// in effect; the show/hide toggle is the section's one action.
<span className="-my-1 flex items-center gap-3">
{hasOverride && <Badge variant="warning">{t('adjusted_badge')}</Badge>}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setExpanded((v) => !v)}
disabled={props.disabled}
>
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
{expanded ? t('hide') : t('show')}
</Button>
</CardHeader>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setExpanded((v) => !v)}
disabled={props.disabled}
>
{expanded ? t('hide') : t('show')}
</Button>
</span>
}
>
{expanded && (
<CardContent className="space-y-4">
<p className="text-xs text-muted-foreground">
{t('description')}
</p>
<>
<SettingsRow label={t('tax_label')} htmlFor="tax_override" align="baseline">
<SettingsInput
id="tax_override"
inputMode="decimal"
placeholder={String(props.taxWithheld)}
value={taxStr}
onChange={(e) => setTaxStr(e.target.value)}
disabled={fieldDisabled}
className={FIELD_CLASS}
/>
<SettingsRowNote>
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.taxWithheld)}</span>
</SettingsRowNote>
</SettingsRow>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="tax_override" className="text-xs">
{t('tax_label')}
</Label>
<Input
id="tax_override"
inputMode="decimal"
placeholder={String(props.taxWithheld)}
value={taxStr}
onChange={(e) => setTaxStr(e.target.value)}
disabled={props.disabled || saving}
// ph-no-capture: the placeholder is the employee's effective
// amount, and replay masking covers values, not attributes.
className="tabular-nums ph-no-capture"
/>
<p className="text-[11px] text-muted-foreground">
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.taxWithheld)}</span>
</p>
</div>
<SettingsRow label={t('avgifter_label')} htmlFor="avgifter_override" align="baseline">
<SettingsInput
id="avgifter_override"
inputMode="decimal"
placeholder={String(props.avgifterAmount)}
value={avgStr}
onChange={(e) => setAvgStr(e.target.value)}
disabled={fieldDisabled}
className={FIELD_CLASS}
/>
<SettingsRowNote>
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.avgifterAmount)}</span>
</SettingsRowNote>
</SettingsRow>
<div className="space-y-1.5">
<Label htmlFor="avgifter_override" className="text-xs">
{t('avgifter_label')}
</Label>
<Input
id="avgifter_override"
inputMode="decimal"
placeholder={String(props.avgifterAmount)}
value={avgStr}
onChange={(e) => setAvgStr(e.target.value)}
disabled={props.disabled || saving}
className="tabular-nums ph-no-capture"
/>
<p className="text-[11px] text-muted-foreground">
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.avgifterAmount)}</span>
</p>
</div>
<SettingsRow label={t('basis_label')} htmlFor="avgifter_basis_override" align="baseline">
<SettingsInput
id="avgifter_basis_override"
inputMode="decimal"
placeholder={String(props.avgifterBasis)}
value={basisStr}
onChange={(e) => setBasisStr(e.target.value)}
disabled={fieldDisabled}
className={FIELD_CLASS}
/>
<SettingsRowNote>
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.avgifterBasis)}</span>
</SettingsRowNote>
</SettingsRow>
<div className="space-y-1.5">
<Label htmlFor="avgifter_basis_override" className="text-xs">
{t('basis_label')}
</Label>
<Input
id="avgifter_basis_override"
inputMode="decimal"
placeholder={String(props.avgifterBasis)}
value={basisStr}
onChange={(e) => setBasisStr(e.target.value)}
disabled={props.disabled || saving}
className="tabular-nums ph-no-capture"
/>
<p className="text-[11px] text-muted-foreground">
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.avgifterBasis)}</span>
</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="override_reason" className="text-xs">
{t('reason_label')}
</Label>
<Textarea
<SettingsRow label={t('reason_label')} htmlFor="override_reason" align="baseline" borderless>
<SettingsTextarea
id="override_reason"
rows={2}
placeholder={t('reason_placeholder')}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={props.disabled || saving}
disabled={fieldDisabled}
/>
</div>
</SettingsRow>
<div className="flex flex-wrap gap-2">
<Button onClick={handleSave} disabled={props.disabled || saving}>
{saving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
<div className="flex flex-wrap gap-2 pt-3">
<Button size="sm" onClick={handleSave} disabled={fieldDisabled}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('save')}
</Button>
{hasOverride && (
<Button
size="sm"
variant="outline"
onClick={handleClear}
disabled={props.disabled || saving}
disabled={fieldDisabled}
>
{t('clear')}
</Button>
)}
</div>
</CardContent>
</>
)}
</Card>
</DetailSection>
)
}
+66 -84
View File
@@ -2,15 +2,17 @@
import { useCallback, useEffect, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { DetailSection, DefRow } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { Download, Loader2, CheckCircle2, ExternalLink } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { downloadFile } from '@/lib/browser/download-file'
import { postAction } from '@/lib/browser/post-action'
import { failureDescription } from '@/lib/browser/action-failure'
import type { ErrorLocale } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
import { cn, formatCurrency, formatDateTime } from '@/lib/utils'
import { roundOre } from '@/lib/money'
interface TaxPaymentPanelProps {
@@ -128,93 +130,73 @@ export function TaxPaymentPanel({
if (totalAmount <= 0) return null
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('tax_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-sm">
<div>
<p className="text-xs text-muted-foreground">{t('tax_label_tax')}</p>
<p className="font-semibold tabular-nums">{formatCurrency(totalTax)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t('tax_label_avgifter')}</p>
<p className="font-semibold tabular-nums">{formatCurrency(totalAvgifter)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t('tax_label_total')}</p>
<p className="font-semibold tabular-nums">{formatCurrency(totalAmount)}</p>
</div>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<p>
{t('tax_recipient')} <span className="text-foreground">{t('tax_recipient_value')}</span>
</p>
<p>
{t('tax_due_date')} <span className="text-foreground tabular-nums">{paymentDeadline}</span>
</p>
<p className="text-xs">
{t('tax_ocr_note')}
</p>
</div>
<DetailSection
kicker={t('tax_title')}
help={<HelpPopover>{t('tax_ocr_note')}</HelpPopover>}
aside={
!readOnly ? (
// Quiet action on the kicker line: the skattekonto at Skatteverket
// is where the payment lands, not something this page does.
<a
href="https://www.skatteverket.se/foretag/skatterochavdrag/skattekonto.4.18e1b10334ebe8bc80004481.html"
target="_blank"
rel="noopener noreferrer"
className={cn(QUIET_LINK_CLASS, 'inline-flex items-center gap-1')}
>
{t('tax_skattekonto_button')}
<ExternalLink className="h-3 w-3" />
</a>
) : undefined
}
>
<div>
<DefRow label={t('tax_label_tax')}>
<span className="tabular-nums">{formatCurrency(totalTax)}</span>
</DefRow>
<DefRow label={t('tax_label_avgifter')}>
<span className="tabular-nums">{formatCurrency(totalAvgifter)}</span>
</DefRow>
<DefRow label={t('tax_label_total')}>
<span className="font-medium tabular-nums">{formatCurrency(totalAmount)}</span>
</DefRow>
<DefRow label={t('tax_recipient')}>{t('tax_recipient_value')}</DefRow>
<DefRow label={t('tax_due_date')}>
<span className="tabular-nums">{paymentDeadline}</span>
</DefRow>
{paymentFileGeneratedAt && (
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-success" />
<div>
{t('tax_file_generated')}{' '}
<span className="text-foreground">
{new Date(paymentFileGeneratedAt).toLocaleString('sv-SE')}
</span>
</div>
</div>
<DefRow label={t('tax_file_generated')}>
<span className="tabular-nums">{formatDateTime(paymentFileGeneratedAt)}</span>
</DefRow>
)}
{taxPaidAt && (
<div className="flex items-start gap-2 text-sm text-success">
<CheckCircle2 className="h-4 w-4 mt-0.5" />
<div>
{t('tax_marked_paid')}{' '}
<span className="font-medium">{new Date(taxPaidAt).toLocaleString('sv-SE')}</span>
</div>
</div>
<DefRow label={t('tax_marked_paid')}>
<span className="tabular-nums">{formatDateTime(taxPaidAt)}</span>
</DefRow>
)}
</div>
{!readOnly && (
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" size="sm" asChild>
<a
href="https://www.skatteverket.se/foretag/skatterochavdrag/skattekonto.4.18e1b10334ebe8bc80004481.html"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" />
{t('tax_skattekonto_button')}
</a>
</Button>
<Button onClick={handleDownload} disabled={downloading || marking}>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{t('tax_download_button')}
</Button>
{!taxPaidAt && (
<Button
variant="outline"
onClick={handleMarkPaid}
disabled={downloading || marking}
>
{marking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{t('tax_mark_paid_button')}
</Button>
{!readOnly && (
<div className="mt-3 flex flex-wrap justify-end gap-2">
<Button onClick={handleDownload} disabled={downloading || marking}>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
</div>
)}
</CardContent>
</Card>
{t('tax_download_button')}
</Button>
{!taxPaidAt && (
<Button
variant="outline"
onClick={handleMarkPaid}
disabled={downloading || marking}
>
{marking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{t('tax_mark_paid_button')}
</Button>
)}
</div>
)}
</DetailSection>
)
}
+11 -13
View File
@@ -1,7 +1,7 @@
'use client'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { DetailSection } from '@/components/ui/detail-section'
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
import { formatCurrency } from '@/lib/utils'
import type { SalaryRunEmployee } from '@/types'
@@ -21,11 +21,8 @@ export function RunCalculationDetails({ periodYear, employees }: RunCalculationD
if (withBreakdown.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('calculation_details_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DetailSection kicker={t('calculation_details_title')}>
<div className="space-y-6">
<TaxTableStatus year={periodYear} compact />
{withBreakdown.map(sre => {
const breakdown = sre.calculation_breakdown as {
@@ -33,20 +30,21 @@ export function RunCalculationDetails({ periodYear, employees }: RunCalculationD
}
const employee = (sre as SreWithEmployee).employee
return (
<div key={sre.id} className="space-y-2">
<div key={sre.id}>
<h4 className="text-sm font-medium">
{employee
? `${employee.first_name} ${employee.last_name}`
: sre.employee_id.slice(0, 8)}
</h4>
<div className="text-xs space-y-1 bg-muted/50 rounded-lg p-3">
{/* Flat calculation steps: one hairline per step, no box. */}
<div className="mt-1 divide-y divide-border text-xs">
{(breakdown?.steps || []).map((step, i) => (
<div key={i} className="flex justify-between gap-4">
<span className="text-muted-foreground">
<div key={i} className="flex justify-between gap-4 py-1">
<span className="min-w-0 text-muted-foreground">
{step.label}: <span className="font-mono">{step.formula}</span>
</span>
{step.output !== null && (
<span className="font-medium tabular-nums">{formatCurrency(step.output)}</span>
<span className="shrink-0 tabular-nums">{formatCurrency(step.output)}</span>
)}
</div>
))}
@@ -54,7 +52,7 @@ export function RunCalculationDetails({ periodYear, employees }: RunCalculationD
</div>
)
})}
</CardContent>
</Card>
</div>
</DetailSection>
)
}
+163 -141
View File
@@ -7,6 +7,8 @@ import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DetailSection } from '@/components/ui/detail-section'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import {
Select,
SelectContent,
@@ -14,11 +16,11 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ChevronRight, FileDown, Loader2, Trash2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { FileDown, Loader2, Trash2 } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import type { EmployeeMasked, SalaryRunEmployee } from '@/types'
import type { RunDetail } from './types'
import { periodLabelOf, type RunDetail } from './types'
type SreWithEmployee = SalaryRunEmployee & {
employee?: {
@@ -67,7 +69,7 @@ export function RunEmployeesTable({
const canRemoveEmployee = run.status === 'draft' && canWrite
const isDraft = run.status === 'draft'
// Δ vs the latest booked run — hidden on the first-ever run and before
// Δ vs the latest booked run: hidden on the first-ever run and before
// calculation (gross is 0 until then, the diff would be noise).
const previous = run.previous_run ?? null
const showDiff = previous != null && isCalculated
@@ -76,6 +78,7 @@ export function RunEmployeesTable({
if (!previous) return null
const prev = previous.by_employee[sre.employee_id]
if (!prev) {
// A new employee is the exception worth a chip; a plain delta is text.
return (
<Badge variant="secondary" className="font-normal">
{t('diff_new_employee')}
@@ -93,13 +96,14 @@ export function RunEmployeesTable({
)
}
const numericTh = cn(TH_CLASS, 'text-right')
const numericTd = cn(TD_CLASS, 'text-right tabular-nums')
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm uppercase tracking-wider text-muted-foreground">
{t('employees_title', { count: employees.length })}
</h2>
{isDraft && canWrite && notAdded.length > 0 && (
<DetailSection
kicker={t('employees_title', { count: employees.length })}
aside={
isDraft && canWrite && notAdded.length > 0 ? (
<Select
key={addEmployeeKey}
onValueChange={value => {
@@ -107,7 +111,7 @@ export function RunEmployeesTable({
setAddEmployeeKey(k => k + 1)
}}
>
<SelectTrigger className="w-[200px] h-8 text-sm">
<SelectTrigger className="-my-1 h-8 w-[200px] text-sm">
<SelectValue placeholder={t('add_employee_placeholder')} />
</SelectTrigger>
<SelectContent>
@@ -118,143 +122,161 @@ export function RunEmployeesTable({
))}
</SelectContent>
</Select>
)}
</div>
) : undefined
}
>
{employees.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
{t('no_employees_yet')}
</div>
<p className="text-sm text-muted-foreground">{t('no_employees_yet')}</p>
) : (
<div className="rounded-lg border border-border divide-y divide-border">
{employees.map(sre => {
const employee = (sre as SreWithEmployee).employee
const name = employee
? `${employee.first_name} ${employee.last_name}`
: `${t('employee_fallback')} ${sre.employee_id.slice(0, 8)}...`
const dims = employee?.default_dimensions ?? {}
const dimLabel = Object.keys(dims)
.sort((a, b) => Number(a) - Number(b))
.map(k => dims[k])
.join(' · ')
const taxValue = sre.tax_withheld_override ?? sre.tax_withheld
const avgifterValue = sre.avgifter_amount_override ?? sre.avgifter_amount
const netValue = sre.net_salary + (sre.tax_withheld - taxValue)
const editableSalary = isDraft && canWrite && sre.salary_type === 'monthly'
const primaryNumber = isDraft ? sre.monthly_salary : sre.gross_salary
// In a draft the number on the row is the full-time monthly salary
// the engine multiplies by the employee's sysselsättningsgrad. Below
// 100 % that product is not obvious from the input alone (a 10 %
// employee typed 45 310 to get a 4 531 kr gross), so spell it out
// right under the number instead of only in Beräkningsdetaljer.
const degree = Number(sre.employment_degree)
const showDegreeHint =
isDraft && sre.salary_type === 'monthly' && Number.isFinite(degree) && degree < 100
const degreeHint = showDegreeHint ? (
<span className="text-[11px] leading-tight text-muted-foreground tabular-nums whitespace-nowrap">
{t('degree_hint', {
degree: degree.toLocaleString('sv-SE'),
amount: formatCurrency(roundOre((sre.monthly_salary || 0) * (degree / 100))),
})}
</span>
) : null
return (
<div
key={sre.id}
className="group flex items-center gap-3 px-4 py-3 hover:bg-secondary/60 transition-colors cursor-pointer"
onClick={() => router.push(`/salary/runs/${runId}/employees/${sre.employee_id}`)}
>
{/* Name + inline meta + (once calculated) a breakdown sub-line */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/salary/runs/${runId}/employees/${sre.employee_id}`}
className="font-medium truncate hover:underline"
onClick={e => e.stopPropagation()}
>
{name}
</Link>
{dimensionsEnabled && dimLabel && (
<Badge data-ph-mask="" variant="secondary">{dimLabel}</Badge>
)}
{showDiff && diffNode(sre)}
</div>
{isCalculated && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
{t('th_tax')} {formatCurrency(taxValue)} · {t('th_net')}{' '}
{formatCurrency(netValue)} · {t('th_avgifter')}{' '}
{formatCurrency(avgifterValue)} · {t('th_vacation')}{' '}
{formatCurrency(sre.vacation_accrual)}
</p>
)}
</div>
{/* Right side: editable monthly salary (draft) or the number */}
{editableSalary ? (
<div className="flex flex-col items-end gap-0.5 shrink-0">
<Input
type="number"
step="0.01"
min="0"
defaultValue={sre.monthly_salary}
onClick={e => e.stopPropagation()}
onBlur={e => onSalaryEdit(sre.employee_id, e.target.value, sre.monthly_salary)}
disabled={actionLoading === `salary-${sre.employee_id}`}
aria-label={t('salary_input_aria', { name })}
className="h-8 w-28 text-right tabular-nums"
/>
{degreeHint}
</div>
) : (
<div className="flex flex-col items-end gap-0.5 shrink-0">
<span className="text-right tabular-nums font-medium">
{formatCurrency(primaryNumber)}
</span>
{degreeHint}
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'pl-0')}>{t('th_employee')}</th>
{showDiff && previous && (
<th className={TH_CLASS}>{t('th_diff', { period: periodLabelOf(previous) })}</th>
)}
<th className={numericTh}>{isDraft ? t('th_monthly_salary') : t('th_gross')}</th>
{isCalculated && (
<>
<th className={numericTh}>{t('th_tax')}</th>
<th className={numericTh}>{t('th_net')}</th>
<th className={numericTh}>{t('th_avgifter')}</th>
<th className={numericTh}>{t('th_vacation')}</th>
</>
)}
<th className={cn(TH_CLASS, 'pr-0 text-right')}>
<span className="sr-only">{t('th_payslip')}</span>
</th>
</tr>
</thead>
<tbody>
{employees.map(sre => {
const employee = (sre as SreWithEmployee).employee
const name = employee
? `${employee.first_name} ${employee.last_name}`
: `${t('employee_fallback')} ${sre.employee_id.slice(0, 8)}...`
const dims = employee?.default_dimensions ?? {}
const dimLabel = Object.keys(dims)
.sort((a, b) => Number(a) - Number(b))
.map(k => dims[k])
.join(' · ')
const taxValue = sre.tax_withheld_override ?? sre.tax_withheld
const avgifterValue = sre.avgifter_amount_override ?? sre.avgifter_amount
const netValue = sre.net_salary + (sre.tax_withheld - taxValue)
const editableSalary = isDraft && canWrite && sre.salary_type === 'monthly'
const primaryNumber = isDraft ? sre.monthly_salary : sre.gross_salary
// In a draft the number on the row is the full-time monthly salary
// the engine multiplies by the employee's sysselsättningsgrad. Below
// 100 % that product is not obvious from the input alone (a 10 %
// employee typed 45 310 to get a 4 531 kr gross), so spell it out
// as a muted suffix right after the number instead of only in
// Beräkningsdetaljer.
const degree = Number(sre.employment_degree)
const showDegreeHint =
isDraft && sre.salary_type === 'monthly' && Number.isFinite(degree) && degree < 100
const degreeHint = showDegreeHint ? (
<span className="text-[11px] text-muted-foreground tabular-nums whitespace-nowrap">
{t('degree_hint', {
degree: degree.toLocaleString('sv-SE'),
amount: formatCurrency(roundOre((sre.monthly_salary || 0) * (degree / 100))),
})}
</span>
) : null
const removing = actionLoading === `remove-${sre.employee_id}`
{/* Payslip PDF */}
<a
href={`/api/salary/runs/${runId}/payslips/${sre.employee_id}/pdf`}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
title={t('view_payslip_title')}
aria-label={t('view_payslip_title')}
>
<FileDown className="h-4 w-4" />
</a>
{canRemoveEmployee && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={e => {
e.stopPropagation()
onRemoveEmployee(sre.employee_id, name)
}}
disabled={actionLoading === `remove-${sre.employee_id}`}
aria-label={t('remove_employee_aria', { name })}
title={t('remove_employee_title')}
return (
<tr
key={sre.id}
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => router.push(`/salary/runs/${runId}/employees/${sre.employee_id}`)}
>
{actionLoading === `remove-${sre.employee_id}` ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
<td className={cn(TD_CLASS, 'pl-0')}>
<Link
href={`/salary/runs/${runId}/employees/${sre.employee_id}`}
className="font-medium hover:underline"
onClick={e => e.stopPropagation()}
>
{name}
</Link>
{dimensionsEnabled && dimLabel && (
<span data-ph-mask="" className="ml-2 text-xs text-muted-foreground">
{dimLabel}
</span>
)}
</td>
{showDiff && <td className={TD_CLASS}>{diffNode(sre)}</td>}
<td className={numericTd}>
<span className="inline-flex items-center justify-end gap-2">
{editableSalary ? (
<Input
type="number"
step="0.01"
min="0"
defaultValue={sre.monthly_salary}
onClick={e => e.stopPropagation()}
onBlur={e => onSalaryEdit(sre.employee_id, e.target.value, sre.monthly_salary)}
disabled={actionLoading === `salary-${sre.employee_id}`}
aria-label={t('salary_input_aria', { name })}
className="-my-1 h-8 w-28 text-right tabular-nums"
/>
) : (
<span>{formatCurrency(primaryNumber)}</span>
)}
{degreeHint}
</span>
</td>
{isCalculated && (
<>
<td className={numericTd}>{formatCurrency(taxValue)}</td>
<td className={numericTd}>{formatCurrency(netValue)}</td>
<td className={numericTd}>{formatCurrency(avgifterValue)}</td>
<td className={numericTd}>{formatCurrency(sre.vacation_accrual)}</td>
</>
)}
</Button>
)}
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</div>
)
})}
<td className={cn(TD_CLASS, 'pr-0 text-right')}>
<span className="-my-1 inline-flex items-center justify-end gap-1">
{/* Payslip PDF */}
<a
href={`/api/salary/runs/${runId}/payslips/${sre.employee_id}/pdf`}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="inline-flex h-8 w-8 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title={t('view_payslip_title')}
aria-label={t('view_payslip_title')}
>
<FileDown className="h-4 w-4" />
</a>
{canRemoveEmployee && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={e => {
e.stopPropagation()
onRemoveEmployee(sre.employee_id, name)
}}
disabled={removing}
aria-label={t('remove_employee_aria', { name })}
title={t('remove_employee_title')}
>
{removing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
)}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</DetailSection>
)
}
+264 -80
View File
@@ -17,123 +17,307 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { AlertTriangle, ArrowLeft, Loader2, MoreVertical, Trash2, Undo2 } from 'lucide-react'
import {
AlertTriangle,
ArrowLeft,
ArrowLeftCircle,
Download,
Eye,
FileDown,
Loader2,
MoreHorizontal,
Send,
Trash2,
Undo2,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { periodLabelOf, type RunDetail } from './types'
// Same chip vocabulary as the Löner list (chips mark exceptions): in-flight
// states wear the quiet beige chip, paid is sage, corrected the outline
// exception, and booked renders as muted text.
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
draft: 'secondary',
review: 'warning',
approved: 'default',
review: 'secondary',
approved: 'secondary',
paid: 'success',
booked: 'success',
corrected: 'outline',
}
const STATUS_RANK: Record<string, number> = {
draft: 0,
review: 1,
approved: 2,
paid: 3,
booked: 4,
corrected: 4,
}
interface RunHeaderProps {
run: RunDetail
canWrite: boolean
actionLoading: string | null
employeeCount: number
isCalculated: boolean
// The single "forward" step for the current status (Beräkna, Till
// granskning, Godkänn, Markera utbetald, Bokför): the one default button
// in the header. Everything else lives behind the ⋯ menu, except the
// payslip send, which is a rail step of its own and stays visible.
primaryAction?: { key: string; label: string; onClick: () => void } | null
onPreview: () => void
onRevert: () => void
onUnapprove: () => void
onSendPayslips: () => void
onDownloadPayslips: () => void
onDownloadAgi: () => void
onDelete: () => void
onCorrect: () => void
}
export function RunHeader({ run, canWrite, actionLoading, employeeCount, onDelete, onCorrect }: RunHeaderProps) {
export function RunHeader({
run,
canWrite,
actionLoading,
employeeCount,
isCalculated,
primaryAction,
onPreview,
onRevert,
onUnapprove,
onSendPayslips,
onDownloadPayslips,
onDownloadAgi,
onDelete,
onCorrect,
}: RunHeaderProps) {
const t = useTranslations('salary_run')
const tSalary = useTranslations('salary')
const [correctOpen, setCorrectOpen] = useState(false)
const periodLabel = periodLabelOf(run)
const hasEmailSend = useCapability(CAPABILITY.email_send)
const statusKey = `status_${run.status}`
const showMenu = canWrite && (run.status === 'draft' || run.status === 'booked')
const rank = STATUS_RANK[run.status] ?? 0
const busy = !!actionLoading
const deliveries = run.payslip_deliveries_summary
// Payslips are a parallel obligation from `approved` onwards. Email send is
// a paid capability (server 403s without it); the PDF download alternative
// in the menu stays free.
const payslipsAvailable = rank >= 2
const showPayslipSend = payslipsAvailable && canWrite
const showPreview = canWrite && (run.status === 'draft' || run.status === 'review')
const showRevert = canWrite && run.status === 'review'
const showUnapprove = canWrite && run.status === 'approved'
const showDownloadPayslips = payslipsAvailable && canWrite
const showDownloadAgi = canWrite && run.status === 'booked'
const showDelete = canWrite && run.status === 'draft'
const showCorrect = canWrite && run.status === 'booked'
const hasMenu =
showPreview ||
showRevert ||
showUnapprove ||
showDownloadPayslips ||
showDownloadAgi ||
showDelete ||
showCorrect
const menuBusy =
actionLoading === 'delete' ||
actionLoading === 'correct' ||
actionLoading === 'preview' ||
actionLoading === 'bulk_payslip' ||
actionLoading === 'agi-download'
const metaParts: React.ReactNode[] = [
<span key="payment">
{t('payment_date_label')} <span className="tabular-nums">{formatDate(run.payment_date)}</span>
</span>,
<span key="count">{t('header_employees', { count: employeeCount })}</span>,
]
if (run.is_correction && run.corrects_run_id) {
metaParts.push(
<Link
key="corrects"
href={`/salary/runs/${run.corrects_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrects_link', { period: periodLabel })}
</Link>,
)
}
if (run.status === 'corrected' && run.corrected_by_run_id) {
metaParts.push(
<Link
key="corrected-by"
href={`/salary/runs/${run.corrected_by_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrected_by_link')}
</Link>,
)
}
return (
<div className="flex items-start gap-2">
<Button variant="ghost" size="icon" asChild className="h-8 w-8 -ml-2 shrink-0">
<Link href="/salary" aria-label={t('back_to_salary')}>
<>
{/* Back link on its own quiet row, so the title below keeps a stable
position across runs. */}
<div>
<Link
href="/salary"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('back_to_salary')}
</Link>
</Button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="font-display text-2xl tracking-tight truncate">
{t('title', { period: periodLabel })}
</h1>
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'} className="shrink-0">
{tSalary(statusKey)}
</Badge>
</div>
{/* Inline property row — Linear-style dot-separated metadata. */}
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>
{t('payment_date_label')} {formatDate(run.payment_date)}
</span>
<span aria-hidden>·</span>
<span>{t('header_employees', { count: employeeCount })}</span>
{run.is_correction && run.corrects_run_id && (
<>
<span aria-hidden>·</span>
<Badge variant="secondary">{t('correction_badge')}</Badge>
<Link
href={`/salary/runs/${run.corrects_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrects_link', { period: periodLabel })}
</Link>
</>
)}
{run.status === 'corrected' && run.corrected_by_run_id && (
<>
<span aria-hidden>·</span>
<Link
href={`/salary/runs/${run.corrected_by_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrected_by_link')}
</Link>
</>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{showMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('more_actions')}>
{actionLoading === 'delete' || actionLoading === 'correct' ? (
<Loader2 className="h-4 w-4 animate-spin" />
{/* Header: serif title with one status element, a quiet meta line, and
the next step on the right. Everything else lives in the ⋯ menu. */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<h1 className="font-display text-2xl leading-8 tracking-tight">
{t('title', { period: periodLabel })}
</h1>
{run.status === 'booked' ? (
<span className="text-sm text-muted-foreground">{tSalary(statusKey)}</span>
) : (
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'}>
{tSalary(statusKey)}
</Badge>
)}
{run.is_correction && run.corrects_run_id && (
<Badge variant="outline">{t('correction_badge')}</Badge>
)}
</div>
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
{metaParts.map((part, i) => (
<span key={i} className="inline-flex items-center gap-x-2">
{i > 0 && <span aria-hidden>·</span>}
{part}
</span>
))}
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{showPayslipSend && (
// The span carries the tooltip: browsers suppress `title` on
// disabled elements, and hover events don't fire on them.
<span title={!hasEmailSend ? t('payslips_send_requires_subscription') : undefined}>
<Button
variant="outline"
onClick={onSendPayslips}
disabled={busy || !hasEmailSend}
>
{actionLoading === 'payslips-send' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
<Send className="mr-2 h-4 w-4" />
)}
{deliveries && deliveries.sent > 0
? t('action_send_payslips_again')
: t('action_send_payslips')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{run.status === 'draft' && (
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t('action_delete_draft')}
</DropdownMenuItem>
)}
{run.status === 'booked' && (
<DropdownMenuItem onClick={() => setCorrectOpen(true)}>
<Undo2 className="mr-2 h-4 w-4" />
{t('action_correct')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</span>
)}
{primaryAction && (
<Button onClick={primaryAction.onClick} disabled={busy}>
{actionLoading === primaryAction.key && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{primaryAction.label}
</Button>
)}
{hasMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('more_actions')}>
{menuBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[240px]">
{showPreview && (
<DropdownMenuItem
onSelect={onPreview}
disabled={busy || (run.status === 'draft' && !isCalculated)}
>
<Eye className="h-4 w-4" />
{t('action_preview')}
</DropdownMenuItem>
)}
{showDownloadPayslips && (
<DropdownMenuItem onSelect={onDownloadPayslips} disabled={busy}>
<FileDown className="h-4 w-4" />
{t('action_download_payslips')}
</DropdownMenuItem>
)}
{showDownloadAgi && (
<DropdownMenuItem onSelect={onDownloadAgi} disabled={busy}>
<Download className="h-4 w-4" />
{t('action_download_agi')}
</DropdownMenuItem>
)}
{(showRevert || showUnapprove) && (
<>
<DropdownMenuSeparator />
{showRevert && (
<DropdownMenuItem onSelect={onRevert} disabled={busy}>
<ArrowLeftCircle className="h-4 w-4" />
{t('action_revert')}
</DropdownMenuItem>
)}
{/* Approval is an internal control point, not a legal
event: the run can be unlocked again as long as nothing
has been paid, booked, or filed. The API refuses once
the AGI has reached Skatteverket. */}
{showUnapprove && (
<DropdownMenuItem onSelect={onUnapprove} disabled={busy}>
<ArrowLeftCircle className="h-4 w-4" />
{t('action_unapprove')}
</DropdownMenuItem>
)}
</>
)}
{(showDelete || showCorrect) && (
<>
<DropdownMenuSeparator />
{showDelete && (
<DropdownMenuItem
onSelect={onDelete}
disabled={busy}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4" />
{t('action_delete_draft')}
</DropdownMenuItem>
)}
{showCorrect && (
<DropdownMenuItem onSelect={() => setCorrectOpen(true)} disabled={busy}>
<Undo2 className="h-4 w-4" />
{t('action_correct')}
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Correction confirm — storno per BFL 5 kap. 5 §, nothing is deleted. */}
{/* Correction confirm: storno per BFL 5 kap. 5 §, nothing is deleted. */}
<Dialog open={correctOpen} onOpenChange={setCorrectOpen}>
<DialogContent>
<DialogHeader>
@@ -163,6 +347,6 @@ export function RunHeader({ run, canWrite, actionLoading, employeeCount, onDelet
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
)
}
+48 -46
View File
@@ -2,9 +2,10 @@
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { DetailSection } from '@/components/ui/detail-section'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { Calculator, Loader2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { cn, formatCurrency } from '@/lib/utils'
export interface EntryPreviewLine {
account_number: string
@@ -31,8 +32,8 @@ export interface PreviewData {
interface RunJournalPreviewProps {
preview: PreviewData
// When provided (draft + write access), a "Beräkna om" button renders in the
// header — recalculation sits on the output it refreshes.
// When provided (draft + write access), a "Beräkna om" button renders on
// the kicker line: recalculation sits on the output it refreshes.
onRecalculate?: () => void
recalculating?: boolean
}
@@ -48,13 +49,11 @@ export function RunJournalPreview({ preview, onRecalculate, recalculating }: Run
].filter(Boolean) as EntryPreview[]
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
<CardTitle className="text-base">
{preview.booked ? t('journal_booked_title') : t('journal_preview_title')}
</CardTitle>
{onRecalculate && (
<Button variant="outline" size="sm" onClick={onRecalculate} disabled={recalculating}>
<DetailSection
kicker={preview.booked ? t('journal_booked_title') : t('journal_preview_title')}
aside={
onRecalculate ? (
<Button variant="outline" size="sm" onClick={onRecalculate} disabled={recalculating} className="-my-1">
{recalculating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
@@ -62,43 +61,46 @@ export function RunJournalPreview({ preview, onRecalculate, recalculating }: Run
)}
{t('action_recalculate')}
</Button>
)}
</CardHeader>
<CardContent className="space-y-6">
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('journal_preview_nollkorning')}</p>
) : (
entries.map((entry, idx) => (
<div key={idx} className="space-y-2">
) : undefined
}
>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('journal_preview_nollkorning')}</p>
) : (
<div className="space-y-6">
{entries.map((entry, idx) => (
<div key={idx}>
<h4 className="text-sm font-medium">{entry.description}</h4>
<table className="w-full text-xs">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1">{t('journal_th_account')}</th>
<th className="text-left py-1">{t('journal_th_description')}</th>
<th className="text-right py-1">{t('journal_th_debit')}</th>
<th className="text-right py-1">{t('journal_th_credit')}</th>
</tr>
</thead>
<tbody>
{entry.lines.map((line, li) => (
<tr key={li} className="border-t border-border">
<td className="py-1.5 tabular-nums font-mono">{line.account_number}</td>
<td className="py-1.5 text-muted-foreground">{line.line_description}</td>
<td className="py-1.5 text-right tabular-nums">
{line.debit_amount ? formatCurrency(line.debit_amount) : ''}
</td>
<td className="py-1.5 text-right tabular-nums">
{line.credit_amount ? formatCurrency(line.credit_amount) : ''}
</td>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'pl-0')}>{t('journal_th_account')}</th>
<th className={TH_CLASS}>{t('journal_th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('journal_th_debit')}</th>
<th className={cn(TH_CLASS, 'pr-0 text-right')}>{t('journal_th_credit')}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{entry.lines.map((line, li) => (
<tr key={li}>
<td className={cn(TD_CLASS, 'pl-0 tabular-nums font-mono')}>{line.account_number}</td>
<td className={cn(TD_CLASS, 'text-muted-foreground')}>{line.line_description}</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>
{line.debit_amount ? formatCurrency(line.debit_amount) : ''}
</td>
<td className={cn(TD_CLASS, 'pr-0 text-right tabular-nums')}>
{line.credit_amount ? formatCurrency(line.credit_amount) : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))
)}
</CardContent>
</Card>
))}
</div>
)}
</DetailSection>
)
}
+5 -5
View File
@@ -45,15 +45,15 @@ export function RunKpiCards({ run, employees }: RunKpiCardsProps) {
{ label: t('kpi_employer_cost'), value: effEmployerCost },
]
// Inline stat row framed by hairlines — Linear-style, no boxes. Metrics
// breathe on whitespace and wrap on narrow viewports.
// Flat stat row: label/number pairs on whitespace, no boxes or rules.
// Metrics wrap on narrow viewports.
return (
<div className="flex flex-wrap gap-x-10 gap-y-3 border-y border-border py-4">
<div className="flex flex-wrap gap-x-10 gap-y-4">
{cards.map(({ label, value, accent }) => (
<div key={label} className="min-w-0">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">{label}</p>
<p
className={`mt-1 font-sans font-medium text-lg tabular-nums leading-none ${accent ? 'text-success' : ''}`}
className={`mt-1 font-display text-xl tabular-nums leading-none ${accent ? 'text-success' : ''}`}
>
{formatCurrency(value)}
</p>
+14 -133
View File
@@ -1,11 +1,7 @@
'use client'
import { useTranslations, useLocale } from 'next-intl'
import { Button } from '@/components/ui/button'
import { ArrowLeftCircle, Eye, FileDown, Loader2, Send } from 'lucide-react'
import { formatDateLong } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { AgiFilingState } from '@/lib/salary/agi-submission-state'
import type { RunDetail } from './types'
@@ -23,19 +19,6 @@ interface RunProgressBarProps {
agiState: AgiFilingState
// Shown on the AGI step once signed: the kvittens is the filing receipt.
agiKvittensnummer?: string | null
canWrite: boolean
actionLoading: string | null
// The single "forward" step for the current status (Beräkna → Skicka till
// granskning → Godkänn → Markera utbetald → Bokför). Rendered as the large
// primary target in the control zone, right next to the secondary actions.
primaryAction?: { key: string; label: string; onClick: () => void } | null
// Secondary actions for the stage: preview, revert, and the parallel payslip
// obligation. (Recalculate lives with the employee rows.)
onPreview: () => void
onRevert: () => void
onUnapprove: () => void
onSendPayslips: () => void
onDownloadPayslips: () => void
}
const STATUS_RANK: Record<string, number> = {
@@ -47,20 +30,19 @@ const STATUS_RANK: Record<string, number> = {
corrected: 4,
}
/**
* The month's steps as a flat segmented rail: no box, no buttons. The actions
* for the current stage live in the header (primary button + ⋯ menu); the
* rail only says where the run stands and what the stage means.
*/
export function RunProgressBar(props: RunProgressBarProps) {
const t = useTranslations('salary_run')
const locale = useLocale()
const { run, isCalculated, noPayout, canWrite, actionLoading, primaryAction, agiState, agiKvittensnummer } = props
const { run, isCalculated, noPayout, agiState, agiKvittensnummer } = props
const rank = STATUS_RANK[run.status] ?? 0
const busy = !!actionLoading
const deliveries = run.payslip_deliveries_summary
const hasEmailSend = useCapability(CAPABILITY.email_send)
function spinnerOr(icon: React.ReactNode, key: string) {
return actionLoading === key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon
}
// Payslips are a parallel obligation from `approved` onwards — never gate
// Payslips are a parallel obligation from `approved` onwards: never gate
// progression, so their "done" is simply having reached every employee.
const payslipsAvailable = rank >= 2
const payslipsDone =
@@ -144,82 +126,6 @@ export function RunProgressBar(props: RunProgressBarProps) {
const doneCount = steps.filter(s => s.state === 'done').length
const activeStep = steps.find(s => s.state === 'active')
// Payslip send/download — shared by the mobile summary and the desktop bar.
// Email send is a paid capability (server 403s without it); the PDF
// download alternative right next to it stays free.
const payslipActions = payslipsAvailable && canWrite && (
<>
{/* The span carries the tooltip: browsers suppress `title` on
disabled elements, and hover events don't fire on them. */}
<span title={!hasEmailSend ? t('payslips_send_requires_subscription') : undefined}>
<Button
size="sm"
variant={deliveries && deliveries.sent > 0 ? 'outline' : 'default'}
onClick={props.onSendPayslips}
disabled={busy || !hasEmailSend}
>
{spinnerOr(<Send className="mr-2 h-4 w-4" />, 'payslips-send')}
{deliveries && deliveries.sent > 0
? t('action_send_payslips_again')
: t('action_send_payslips')}
</Button>
</span>
<Button variant="ghost" size="sm" onClick={props.onDownloadPayslips} disabled={busy}>
{spinnerOr(<FileDown className="mr-2 h-4 w-4" />, 'bulk_payslip')}
{t('action_download_payslips')}
</Button>
</>
)
// Secondary actions for the current status. The forward/primary action is
// the header CTA, so this is deliberately the "everything else" set.
let secondaryActions: React.ReactNode = null
if (canWrite) {
if (run.status === 'draft') {
// Recalculate ("Beräkna om") lives with the employee rows it recalculates,
// not here. This band keeps only the preview toggle.
secondaryActions = (
<Button
variant="outline"
size="sm"
onClick={props.onPreview}
disabled={busy || !isCalculated}
>
{spinnerOr(<Eye className="mr-2 h-4 w-4" />, 'preview')}
{t('action_preview')}
</Button>
)
} else if (run.status === 'review') {
secondaryActions = (
<>
<Button variant="outline" size="sm" onClick={props.onPreview} disabled={busy}>
{spinnerOr(<Eye className="mr-2 h-4 w-4" />, 'preview')}
{t('action_preview')}
</Button>
<Button variant="ghost" size="sm" onClick={props.onRevert} disabled={busy}>
<ArrowLeftCircle className="mr-2 h-4 w-4" />
{t('action_revert')}
</Button>
</>
)
} else if (run.status === 'approved') {
// Approval is an internal control point, not a legal event — the run can
// be unlocked again as long as nothing has been paid, booked, or filed.
// The API refuses once the AGI has reached Skatteverket.
secondaryActions = (
<>
{payslipActions}
<Button variant="ghost" size="sm" onClick={props.onUnapprove} disabled={busy}>
<ArrowLeftCircle className="mr-2 h-4 w-4" />
{t('action_unapprove')}
</Button>
</>
)
} else if (payslipsAvailable) {
secondaryActions = payslipActions
}
}
function segClass(state: StepState) {
return state === 'done'
? 'bg-primary'
@@ -228,19 +134,12 @@ export function RunProgressBar(props: RunProgressBarProps) {
: 'bg-border'
}
// The stage line + its detail — a single sentence that sits under the track.
// The stage line + its detail: a single sentence that sits under the track.
const currentLine = activeStep?.detail ?? (activeStep ? activeStep.label : t('rail_all_done'))
const primaryButton = primaryAction && (
<Button size="sm" onClick={primaryAction.onClick} disabled={busy}>
{actionLoading === primaryAction.key && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{primaryAction.label}
</Button>
)
return (
<div className="rounded-lg border border-border p-4">
{/* Mobile: segmented track + current step + a full-width primary button. */}
<div>
{/* Mobile: segmented track + current step + counter. */}
<div className="md:hidden space-y-3">
<div className="flex gap-1">
{steps.map(step => (
@@ -258,20 +157,11 @@ export function RunProgressBar(props: RunProgressBarProps) {
{activeStep?.detail && (
<p className="text-[11px] text-muted-foreground">{activeStep.detail}</p>
)}
{primaryAction && (
<Button className="w-full" onClick={primaryAction.onClick} disabled={busy}>
{actionLoading === primaryAction.key && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{primaryAction.label}
</Button>
)}
{secondaryActions && <div className="flex flex-wrap gap-2">{secondaryActions}</div>}
</div>
{/* Desktop: segmented track with per-segment labels, then a single action
row — the stage sentence on the left, secondary + primary on the right. */}
<div className="hidden md:block space-y-4">
{/* Desktop: segmented track with per-segment labels, then the stage
sentence. */}
<div className="hidden md:block space-y-3">
<ol className="flex gap-2">
{steps.map(step => (
<li key={step.key} className="flex-1 min-w-0 space-y-2">
@@ -291,16 +181,7 @@ export function RunProgressBar(props: RunProgressBarProps) {
</li>
))}
</ol>
{(secondaryActions || primaryButton) && (
<div className="flex items-center justify-between gap-4">
<p className="text-xs text-muted-foreground">{currentLine}</p>
<div className="flex flex-wrap items-center justify-end gap-2 shrink-0">
{secondaryActions}
{primaryButton}
</div>
</div>
)}
<p className="text-xs text-muted-foreground">{currentLine}</p>
</div>
</div>
)
+5 -1
View File
@@ -11,11 +11,14 @@ import { cn } from '@/lib/utils'
export function DetailSection({
kicker,
help,
aside,
children,
className,
}: {
kicker: string
/** Section help behind a "?" right after the kicker (convention 7). Pass a <HelpPopover>. */
help?: React.ReactNode
/** Optional right-aligned element on the kicker line: a count, a quiet action. */
aside?: React.ReactNode
children: React.ReactNode
@@ -25,8 +28,9 @@ export function DetailSection({
<section className={className}>
<div className="flex items-baseline justify-between gap-4 border-b border-border pb-2">
{/* data-ph-unmask: kickers are static i18n chrome in session replays. */}
<h2 data-ph-unmask="" className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<h2 data-ph-unmask="" className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{kicker}
{help}
</h2>
{aside}
</div>