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