feat(salary): concept Löner landing + nav collapse animation (UI migration PR 9) (#1130)

* feat(salary): concept scene 22 landing + smoother nav collapse

Loner gets the concept header (quiet Anstallda link + Starta lonekorning
primary), the hero de-cards to a flat serif line + description + pill,
and the runs list becomes the scene 22 dry-table (Period, Status,
Anstallda, Bruttolon, Netto) where booked runs read as muted text and
in-flight runs carry a chip plus the payout date. Attention cards and the
hero state machine survive unchanged.

Nav polish: the collapse now animates as one movement (300ms decelerating
curve on both the aside width and the panel margin) and the rail/full
contents slide+fade in on swap; the Register/Bokslut folds match the
RowFoldout timing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(salary): strip hero and attention cards from the landing

Founder direction: the Loner landing is header + lonekorningar only
(concept scene 22). The open run's table row is the entry point; AGI,
skatt, blockers and semester surfaces live on the run detail and the
employee register. Drops the hero state machine, the four cards, and all
their data plumbing (deadlines query, tax-payment chain, SKV status,
AGI submission hook).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): drop the Lonekorningar eyebrow

Founder feedback: the uppercase eyebrow read as a stray font above the
table, and with the landing reduced to the runs table alone the label is
redundant under the Loner page title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): populate the Anstallda column via a count embed

The runs list selected bare salary_runs rows, so the landing's Anstallda
column was always empty (the employees array only ever existed on the
detail response). The list now embeds employee_count via
salary_run_employees(count) and the page reads the PostgREST count shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): quiet beige chip for in-flight run states

Founder feedback vs the concept: the Granskning chip rendered as the
bordered ochre warning badge and read noisy next to the concept's quiet
Utkast chip. Draft/review/approved now all wear the beige secondary chip;
the payout-date note carries the urgency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-23 22:17:20 +02:00
committed by GitHub
parent 658c274d97
commit 2175eccddd
6 changed files with 143 additions and 500 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ const MAIN_PANEL_CLASS =
'safe-area-main-padding md:!pb-0 relative bg-background min-h-screen ' +
'md:min-h-0 md:ml-[var(--nav-w)] md:mt-[10px] md:mr-[10px] md:h-[calc(100vh-20px)] ' +
'md:overflow-y-auto md:rounded-xl md:border md:border-border ' +
'md:transition-[margin-left] md:duration-200'
'md:transition-[margin-left] md:duration-300 md:ease-[cubic-bezier(0.32,0.72,0,1)]'
export default async function DashboardLayout({
children,
+122 -492
View File
@@ -4,27 +4,18 @@ import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { ArrowRight, CalendarClock, CheckCircle2, HandCoins, Loader2, Plus, UserX, Users } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { HandCoins, Loader2, Plus, Users } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { VacationBalanceCard } from '@/components/salary/VacationBalanceCard'
import { useAgiSubmission } from '@/lib/hooks/use-agi-submission'
import { deriveAgiFilingState } from '@/lib/salary/agi-submission-state'
import { useCompany } from '@/contexts/CompanyContext'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import type { Employee, SalaryRun } from '@/types'
const supabase = createClient()
const STATUS_LABEL_KEYS: Record<string, string> = {
draft: 'status_draft',
review: 'status_review',
@@ -34,87 +25,48 @@ const STATUS_LABEL_KEYS: Record<string, string> = {
corrected: 'status_corrected',
}
// In-flight states all wear the quiet beige chip (concept scene 22's Utkast
// look): the payout-date note beside the chip carries the urgency, not an
// ochre border. Booked renders as muted text, corrected as the outline
// exception.
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',
}
interface TaxPaymentState {
tax_payment_file_generated_at: string | null
tax_paid_at: string | null
}
/**
* Löner landing (concept scene 22): header + the lönekörningar dry-table,
* nothing else. The open run's row is the way into the flow (chip + payout
* date); AGI, skatt, blockers and semester live on the run detail and the
* employee register.
*/
export default function SalaryPage() {
const [runs, setRuns] = useState<SalaryRun[]>([])
const [employees, setEmployees] = useState<Employee[]>([])
const [payDay, setPayDay] = useState(25)
const [agiDeadline, setAgiDeadline] = useState<{ due_date: string; title: string } | null>(null)
const [taxPayment, setTaxPayment] = useState<TaxPaymentState | null>(null)
// The Skatteverket connection previously worked but now needs re-consent:
// the skattekonto sync (which auto-settles the tax card) is paused.
const [skvNeedsReconsent, setSkvNeedsReconsent] = useState(false)
const [loading, setLoading] = useState(true)
const [starting, setStarting] = useState(false)
const [markingPaid, setMarkingPaid] = useState(false)
const { canWrite } = useCanWrite()
const { company } = useCompany()
const { toast } = useToast()
const router = useRouter()
const t = useTranslations('salary')
const tp = useTranslations('salary_payments')
const load = useCallback(async () => {
// Everything loads in parallel; the tax-payment fetch is the only
// dependent request and chains directly off the runs response instead of
// waiting for the whole batch. Was three sequential legs (batch → tax
// payment → SKV status), now the longest chain is runs → tax payment.
const runsPromise: Promise<SalaryRun[]> = fetch('/api/salary/runs')
.then(async res => (res.ok ? (await res.json()).data || [] : []))
.catch(() => [])
// Latest booked run drives the "skatt att betala" card. Resolves to
// undefined ("leave state unchanged") when there is no booked run or the
// fetch fails, mirroring the old sequential behavior on reloads.
const taxPaymentPromise: Promise<TaxPaymentState | null | undefined> = runsPromise
.then(async loadedRuns => {
const latestBooked = loadedRuns.find(r => r.status === 'booked')
if (!latestBooked) return undefined
const period = `${latestBooked.period_year}-${String(latestBooked.period_month).padStart(2, '0')}`
const txRes = await fetch(`/api/skatteverket/tax-payments/${period}`)
if (!txRes.ok) return undefined
return (await txRes.json()).data ?? null
})
.catch(() => undefined)
const [loadedRuns, taxPaymentData, empRes, settingsRes, skvStatus] = await Promise.all([
runsPromise,
taxPaymentPromise,
const [runsRes, empRes] = await Promise.all([
fetch('/api/salary/runs').catch(() => null),
fetch('/api/salary/employees').catch(() => null),
fetch('/api/settings').catch(() => null),
// Connection health for the tax card hint. Only needs_reconsent counts:
// the routine short-lived token expiry is normal and must not nag. Any
// failure (extension disabled → 503, network) silently means no hint.
fetch('/api/extensions/ext/skatteverket/status')
.then(res => (res.ok ? res.json() : null))
.catch(() => null),
])
setRuns(loadedRuns)
if (taxPaymentData !== undefined) setTaxPayment(taxPaymentData)
if (runsRes?.ok) {
const { data } = await runsRes.json()
setRuns(data || [])
}
if (empRes?.ok) {
const { data } = await empRes.json()
setEmployees(data || [])
}
if (settingsRes?.ok) {
const { data } = await settingsRes.json()
if (typeof data?.salary_pay_day === 'number') setPayDay(data.salary_pay_day)
}
if (skvStatus) setSkvNeedsReconsent(skvStatus.needsReconsent === true)
setLoading(false)
}, [])
@@ -122,51 +74,6 @@ export default function SalaryPage() {
load()
}, [load])
// Reload after an in-page Skatteverket reconnect. The raw postMessage from
// the BankID popup is only trusted by the component that opened and
// source-verified the popup (SkatteverketConnectPanel / AGIPanel); this
// page never opens the popup itself, so it consumes the verified rebroadcast
// instead. The OAuth callback awaits the skattekonto sync + AGI
// auto-settlement before responding, so this refetch already sees fresh
// tax-payment state instead of racing a background job.
useEffect(() => {
function handleConnectionUpdated() {
load()
}
window.addEventListener('skatteverket-connection-updated', handleConnectionUpdated)
return () =>
window.removeEventListener('skatteverket-connection-updated', handleConnectionUpdated)
}, [load])
// Next open AGI deadline instance - generated by the tax-deadline engine
// when the company pays salaries; same source as the /deadlines page.
useEffect(() => {
if (!company) return
const today = new Date().toISOString().split('T')[0]
supabase
.from('deadlines')
.select('due_date, title')
.eq('company_id', company.id)
.eq('tax_deadline_type', 'arbetsgivardeklaration')
.eq('is_completed', false)
.is('dismissed_at', null)
.gte('due_date', today)
.order('due_date')
.limit(1)
.maybeSingle()
.then(({ data }) => setAgiDeadline(data ?? null))
}, [company])
// The active run's AGI submission record: lets the hero distinguish
// "lämna in till Skatteverket" from "väntar på din BankID-signatur".
// Only fetched while a booked run is still unfiled; null otherwise.
const activeRun = runs.find(r => r.status !== 'corrected')
const { submission: agiSubmission } = useAgiSubmission(
activeRun && activeRun.status === 'booked' && !activeRun.agi_submitted_at
? `${activeRun.period_year}${String(activeRun.period_month).padStart(2, '0')}`
: null,
)
// One-click run creation: the API seeds all active employees, calculates,
// and resolves period/pay-date/series defaults from settings.
async function startRun() {
@@ -198,353 +105,56 @@ export default function SalaryPage() {
}
}
// Inline mark-paid on the tax card: same endpoint as TaxPaymentPanel on the
// run detail page, for users who paid Skatteverket outside the app.
async function markTaxPaid(period: string) {
setMarkingPaid(true)
try {
const res = await fetch(`/api/skatteverket/tax-payments/${period}/mark-paid`, {
method: 'POST',
})
if (!res.ok) {
const result = await res.json().catch(() => null)
toast({
title: tp('tax_mark_paid_failed_title'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
return
}
toast({ title: tp('tax_marked_paid') })
const txRes = await fetch(`/api/skatteverket/tax-payments/${period}`)
if (txRes.ok) {
const tx = await txRes.json()
setTaxPayment(tx.data)
}
} finally {
setMarkingPaid(false)
}
}
const periodOf = (r: SalaryRun) => `${r.period_year}-${String(r.period_month).padStart(2, '0')}`
const header = (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('title')}</h1>
<div className="flex items-center gap-4">
<Link href="/salary/employees" className={QUIET_LINK_CLASS}>
{t('employees')}
</Link>
{canWrite && (
<Button onClick={startRun} disabled={starting || loading}>
{starting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
{t('start_run')}
</Button>
)}
</div>
</div>
)
if (loading) {
// Real header renders immediately; only the data surfaces are skeletons.
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
action={
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href="/salary/employees">
<Users className="mr-2 h-4 w-4" />
{t('employees')}
</Link>
</Button>
{canWrite && (
<Button disabled>
<Plus className="mr-2 h-4 w-4" />
{t('start_run')}
</Button>
)}
</div>
}
/>
<Skeleton className="h-28 rounded-lg" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{header}
<div className="space-y-3">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-24 rounded-lg" />
<Skeleton key={i} className="h-10 rounded-lg" />
))}
</div>
</div>
)
}
// ── Hero state machine (first match wins) ────────────────────────────────
// activeRun is derived above the loading return (the AGI submission hook
// needs it before any early return).
const latestBooked = runs.find(r => r.status === 'booked')
const periodOf = (r: SalaryRun) => `${r.period_year}-${String(r.period_month).padStart(2, '0')}`
// Next period for the quiet state: month after the latest non-corrected run.
const nextPeriod = (() => {
if (!activeRun) {
const now = new Date()
return { year: now.getFullYear(), month: now.getMonth() + 1 }
}
return activeRun.period_month === 12
? { year: activeRun.period_year + 1, month: 1 }
: { year: activeRun.period_year, month: activeRun.period_month + 1 }
})()
const nextPayDate = `${nextPeriod.year}-${String(nextPeriod.month).padStart(2, '0')}-${String(payDay).padStart(2, '0')}`
type Hero =
| { kind: 'onboarding' }
| { kind: 'cta'; title: string; description: string; label: string; runId: string }
| { kind: 'quiet' }
const hero: Hero = (() => {
if (runs.length === 0 && employees.length === 0) return { kind: 'onboarding' }
if (activeRun && (activeRun.status === 'draft' || activeRun.status === 'review')) {
return {
kind: 'cta',
title: t('hero_review_title', { period: periodOf(activeRun) }),
description: t('hero_review_description', {
count: (activeRun as SalaryRun & { employees?: unknown[] }).employees?.length ?? employees.length,
net: formatCurrency(activeRun.total_net),
date: formatDate(activeRun.payment_date),
}),
label: t('hero_review_action'),
runId: activeRun.id,
}
}
if (activeRun && activeRun.status === 'approved') {
// A run that pays out nothing (nollkörning, or fully net-deducted) has no
// payment file to download - don't send the user to "pay". The real next
// step is to post it and file AGI, so shepherd them into the run instead.
const noPayout = Math.round((activeRun.total_net ?? 0) * 100) === 0
if (noPayout) {
return {
kind: 'cta',
title: t('hero_finish_title', { period: periodOf(activeRun) }),
description: t('hero_finish_description'),
label: t('hero_finish_action'),
runId: activeRun.id,
}
}
return {
kind: 'cta',
title: t('hero_pay_title', { period: periodOf(activeRun) }),
description: t('hero_pay_description', {
net: formatCurrency(activeRun.total_net),
date: formatDate(activeRun.payment_date),
}),
label: t('hero_pay_action'),
runId: activeRun.id,
}
}
if (activeRun && activeRun.status === 'paid') {
return {
kind: 'cta',
title: t('hero_book_title', { period: periodOf(activeRun) }),
description: t('hero_book_description'),
label: t('hero_book_action'),
runId: activeRun.id,
}
}
if (activeRun && activeRun.status === 'booked' && !activeRun.agi_submitted_at) {
// The underlag may already be at Skatteverket waiting for a BankID
// signature: telling the user to "lämna in" something they already
// submitted reads as a broken flow. Follow the real filing state.
const agiState = deriveAgiFilingState(activeRun, agiSubmission)
if (agiState === 'awaiting_signing' || agiState === 'underlag_submitted') {
return {
kind: 'cta',
title: t('hero_agi_signing_title', { period: periodOf(activeRun) }),
description: t('hero_agi_signing_description'),
label: t('hero_agi_signing_action'),
runId: activeRun.id,
}
}
return {
kind: 'cta',
title: t('hero_agi_title', { period: periodOf(activeRun) }),
description: t('hero_agi_description'),
label: t('hero_agi_action'),
runId: activeRun.id,
}
}
return { kind: 'quiet' }
})()
// ── Blockers: active employees missing what a run needs ──────────────────
const missingBank = employees.filter(e => !e.clearing_number || !e.bank_account_number).length
const missingEmail = employees.filter(e => !e.email).length
const blockerCount = missingBank + missingEmail
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
action={
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href="/salary/employees">
<Users className="mr-2 h-4 w-4" />
{t('employees')}
</Link>
</Button>
{canWrite && (
<Button onClick={startRun} disabled={starting}>
{starting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
{t('start_run')}
</Button>
)}
</div>
}
/>
{header}
{/* Hero - the one thing to do now */}
{hero.kind === 'onboarding' ? (
<Card>
<CardContent className="p-0">
<EmptyState
icon={Users}
title={t('onboarding_title')}
description={t('onboarding_description')}
actionLabel={canWrite ? t('onboarding_action') : undefined}
actionHref={canWrite ? '/salary/employees/new' : undefined}
/>
</CardContent>
</Card>
) : hero.kind === 'cta' ? (
<Card>
<CardContent className="p-6 flex flex-col md:flex-row md:items-center gap-4 justify-between">
<div className="space-y-1 min-w-0">
<h2 className="font-display text-xl md:text-2xl tracking-tight">{hero.title}</h2>
<p className="text-sm text-muted-foreground">{hero.description}</p>
</div>
<Button asChild className="shrink-0">
<Link href={`/salary/runs/${hero.runId}`}>
{hero.label}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardContent>
</Card>
{runs.length === 0 && employees.length === 0 ? (
<EmptyState
icon={Users}
title={t('onboarding_title')}
description={t('onboarding_description')}
actionLabel={canWrite ? t('onboarding_action') : undefined}
actionHref={canWrite ? '/salary/employees/new' : undefined}
/>
) : (
<Card>
<CardContent className="p-6 flex flex-col md:flex-row md:items-center gap-4 justify-between">
<div className="space-y-1 min-w-0">
<h2 className="font-display text-xl md:text-2xl tracking-tight">
{t('quiet_title', {
period: `${nextPeriod.year}-${String(nextPeriod.month).padStart(2, '0')}`,
})}
</h2>
<p className="text-sm text-muted-foreground">
{t('quiet_description', { date: formatDate(nextPayDate) })}
</p>
</div>
{canWrite && (
<Button variant="outline" onClick={startRun} disabled={starting} className="shrink-0">
{starting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('quiet_action')}
</Button>
)}
</CardContent>
</Card>
)}
{/* Attention cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<CalendarClock className="h-4 w-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">{t('card_agi_title')}</p>
</div>
{agiDeadline ? (
<>
<p className="font-sans text-lg font-medium tabular-nums leading-tight">
{formatDate(agiDeadline.due_date)}
</p>
<Link
href="/deadlines"
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
>
{agiDeadline.title}
</Link>
</>
) : (
<p className="text-sm text-muted-foreground">{t('card_agi_none')}</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<HandCoins className="h-4 w-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">{t('card_tax_title')}</p>
</div>
{latestBooked ? (
<>
<p className="font-sans text-lg font-medium tabular-nums leading-tight">
{formatCurrency(latestBooked.total_tax + latestBooked.total_avgifter)}
</p>
<p className="text-xs text-muted-foreground">
{taxPayment?.tax_paid_at
? t('card_tax_paid', { date: formatDate(taxPayment.tax_paid_at) })
: t('card_tax_unpaid', { period: periodOf(latestBooked) })}
</p>
{!taxPayment?.tax_paid_at && skvNeedsReconsent && (
<Link
href="/settings/tax"
className="mt-1 block text-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
>
{t('card_tax_reconnect')}
</Link>
)}
{!taxPayment?.tax_paid_at && canWrite && (
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => markTaxPaid(periodOf(latestBooked))}
disabled={markingPaid}
>
{markingPaid ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{tp('tax_mark_paid_button')}
</Button>
)}
</>
) : (
<p className="text-sm text-muted-foreground">{t('card_tax_none')}</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<UserX className="h-4 w-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">{t('card_blockers_title')}</p>
</div>
{blockerCount > 0 ? (
<>
<p className="font-sans text-lg font-medium tabular-nums leading-tight">
{blockerCount}
</p>
<Link
href="/salary/employees"
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
>
{t('card_blockers_detail', { bank: missingBank, email: missingEmail })}
</Link>
</>
) : (
<p className="text-sm text-muted-foreground">{t('card_blockers_none')}</p>
)}
</CardContent>
</Card>
{/* Semester (vacation ledger + year close): payroll gap-closure 3.5 */}
<VacationBalanceCard canWrite={canWrite} />
</div>
{/* History */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t('runs_title')}</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div>
{runs.length === 0 ? (
<EmptyState
icon={HandCoins}
@@ -554,53 +164,73 @@ export default function SalaryPage() {
onAction={canWrite ? startRun : undefined}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('th_period')}</TableHead>
<TableHead>{t('th_payday')}</TableHead>
<TableHead className="text-right">{t('th_gross')}</TableHead>
<TableHead className="text-right">{t('th_net')}</TableHead>
<TableHead className="text-right">{t('th_contributions')}</TableHead>
<TableHead>{t('th_status')}</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.slice(0, 12).map(run => (
<TableRow key={run.id}>
<TableCell className="font-medium tabular-nums">
{periodOf(run)}
</TableCell>
<TableCell className="text-muted-foreground tabular-nums">
{formatDate(run.payment_date)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(run.total_gross)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(run.total_net)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(run.total_avgifter)}
</TableCell>
<TableCell>
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'}>
{STATUS_LABEL_KEYS[run.status] ? t(STATUS_LABEL_KEYS[run.status]) : run.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Link href={`/salary/runs/${run.id}`} className="text-muted-foreground hover:text-foreground">
<ArrowRight className="h-4 w-4" />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>{t('th_period')}</th>
<th className={cn(TH_CLASS, 'w-full')}>{t('th_status')}</th>
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>{t('th_employees')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_gross')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_net')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{runs.slice(0, 12).map(run => {
// PostgREST count embed: employee_count is [{ count: n }].
const employeeCount = (
run as SalaryRun & { employee_count?: { count: number }[] }
).employee_count?.[0]?.count
const inFlight = run.status !== 'booked' && run.status !== 'corrected'
return (
<tr
key={run.id}
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => router.push(`/salary/runs/${run.id}`)}
>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')}>
<Link
href={`/salary/runs/${run.id}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{periodOf(run)}
</Link>
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full whitespace-nowrap')}>
<span className="inline-flex items-center gap-2">
{run.status === 'booked' ? (
<span className="text-muted-foreground">{t('status_booked')}</span>
) : (
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'} className="font-normal">
{STATUS_LABEL_KEYS[run.status] ? t(STATUS_LABEL_KEYS[run.status]) : run.status}
</Badge>
)}
{inFlight && (
<span className="text-[11.5px] text-muted-foreground tabular-nums">
{t('run_payout_note', { date: formatDate(run.payment_date) })}
</span>
)}
</span>
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums sm:table-cell')}>
{employeeCount ?? ''}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums sensitive-field')}>
{formatCurrency(run.total_gross)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums sensitive-field')}>
{formatCurrency(run.total_net)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
)
}
+3 -1
View File
@@ -20,9 +20,11 @@ export const GET = withRouteContext(
const { searchParams } = new URL(request.url)
const year = searchParams.get('year')
// employee_count feeds the Anställda column on the Löner landing; the
// embedded count avoids shipping the per-employee rows with the list.
let query = supabase
.from('salary_runs')
.select('*')
.select('*, employee_count:salary_run_employees(count)')
.eq('company_id', companyId)
if (year) {
+11 -4
View File
@@ -622,7 +622,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
/>
</button>
<div
className="grid transition-[grid-template-rows] duration-200 ease-out"
className="grid transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
>
<div className="overflow-hidden">
@@ -677,7 +677,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
return (
<>
{/* Desktop sidebar */}
<aside className="hidden md:fixed md:inset-y-0 md:z-10 md:flex md:w-[var(--nav-w)] md:flex-col md:transition-[width] md:duration-200">
{/* Width, and the panel margin beside it, share the same 300ms
decelerating curve so collapse reads as one movement. */}
<aside className="hidden md:fixed md:inset-y-0 md:z-10 md:flex md:w-[var(--nav-w)] md:flex-col md:transition-[width] md:duration-300 md:ease-[cubic-bezier(0.32,0.72,0,1)]">
{/* Borderless on the frame: the panel next to it carries the border */}
<div className="flex min-h-0 flex-1 flex-col bg-transparent">
{/* Sidebar header: brand mark left, collapse toggle right. The
@@ -722,8 +724,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
below stays sticky (concept PR 2). */}
<div className="flex-1 min-h-0 overflow-y-auto pt-1 pb-2">
{collapsed ? (
/* The rail/full swap remounts, so each side slides+fades in
while the aside width animates: one smooth movement. */
<nav
className="flex flex-col items-center gap-px px-2"
className="flex flex-col items-center gap-px px-2 animate-in fade-in slide-in-from-right-2 duration-200"
aria-label={tNav('main_navigation')}
>
{railItems.map((item) => renderRailItem(item))}
@@ -754,7 +758,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
})}
</nav>
) : (
<nav className="px-3" aria-label={tNav('main_navigation')}>
<nav
className="px-3 animate-in fade-in slide-in-from-left-2 duration-200"
aria-label={tNav('main_navigation')}
>
{/* Top section: flat, no header. Hem, Assistent. */}
<div className="mb-4 space-y-px">
{topItems.map((item) => renderSidebarItem(item))}
+3 -1
View File
@@ -5258,7 +5258,9 @@
"status_approved": "Approved",
"status_paid": "Paid",
"status_booked": "Posted",
"status_corrected": "Corrected"
"status_corrected": "Corrected",
"th_employees": "Employees",
"run_payout_note": "payout {date}"
},
"salary_run": {
"title": "Payroll run {period}",
+3 -1
View File
@@ -5258,7 +5258,9 @@
"status_approved": "Godkänd",
"status_paid": "Betald",
"status_booked": "Bokförd",
"status_corrected": "Korrigerad"
"status_corrected": "Korrigerad",
"th_employees": "Anställda",
"run_payout_note": "utbetalning {date}"
},
"salary_run": {
"title": "Lönekörning {period}",