diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index ab194f6d..c3218c32 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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, diff --git a/app/(dashboard)/salary/page.tsx b/app/(dashboard)/salary/page.tsx index f687e039..f2780cb1 100644 --- a/app/(dashboard)/salary/page.tsx +++ b/app/(dashboard)/salary/page.tsx @@ -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 = { draft: 'status_draft', review: 'status_review', @@ -34,87 +25,48 @@ const STATUS_LABEL_KEYS: Record = { 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 = { 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([]) const [employees, setEmployees] = useState([]) - const [payDay, setPayDay] = useState(25) - const [agiDeadline, setAgiDeadline] = useState<{ due_date: string; title: string } | null>(null) - const [taxPayment, setTaxPayment] = useState(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 = 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 = 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 = ( +
+

{t('title')}

+
+ + {t('employees')} + + {canWrite && ( + + )} +
+
+ ) if (loading) { - // Real header renders immediately; only the data surfaces are skeletons. return (
- - - {canWrite && ( - - )} -
- } - /> - -
+ {header} +
{[1, 2, 3].map(i => ( - + ))}
) } - // ── 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 (
- - - {canWrite && ( - - )} -
- } - /> + {header} - {/* Hero - the one thing to do now */} - {hero.kind === 'onboarding' ? ( - - - - - - ) : hero.kind === 'cta' ? ( - - -
-

{hero.title}

-

{hero.description}

-
- -
-
+ {runs.length === 0 && employees.length === 0 ? ( + ) : ( - - -
-

- {t('quiet_title', { - period: `${nextPeriod.year}-${String(nextPeriod.month).padStart(2, '0')}`, - })} -

-

- {t('quiet_description', { date: formatDate(nextPayDate) })} -

-
- {canWrite && ( - - )} -
-
- )} - - {/* Attention cards */} -
- - -
- -

{t('card_agi_title')}

-
- {agiDeadline ? ( - <> -

- {formatDate(agiDeadline.due_date)} -

- - {agiDeadline.title} - - - ) : ( -

{t('card_agi_none')}

- )} -
-
- - - -
- -

{t('card_tax_title')}

-
- {latestBooked ? ( - <> -

- {formatCurrency(latestBooked.total_tax + latestBooked.total_avgifter)} -

-

- {taxPayment?.tax_paid_at - ? t('card_tax_paid', { date: formatDate(taxPayment.tax_paid_at) }) - : t('card_tax_unpaid', { period: periodOf(latestBooked) })} -

- {!taxPayment?.tax_paid_at && skvNeedsReconsent && ( - - {t('card_tax_reconnect')} - - )} - {!taxPayment?.tax_paid_at && canWrite && ( - - )} - - ) : ( -

{t('card_tax_none')}

- )} -
-
- - - -
- -

{t('card_blockers_title')}

-
- {blockerCount > 0 ? ( - <> -

- {blockerCount} -

- - {t('card_blockers_detail', { bank: missingBank, email: missingEmail })} - - - ) : ( -

{t('card_blockers_none')}

- )} -
-
- - {/* Semester (vacation ledger + year close): payroll gap-closure 3.5 */} - -
- - {/* History */} - - - {t('runs_title')} - - +
{runs.length === 0 ? ( ) : ( - - - - {t('th_period')} - {t('th_payday')} - {t('th_gross')} - {t('th_net')} - {t('th_contributions')} - {t('th_status')} - - - - - {runs.slice(0, 12).map(run => ( - - - {periodOf(run)} - - - {formatDate(run.payment_date)} - - - {formatCurrency(run.total_gross)} - - - {formatCurrency(run.total_net)} - - - {formatCurrency(run.total_avgifter)} - - - - {STATUS_LABEL_KEYS[run.status] ? t(STATUS_LABEL_KEYS[run.status]) : run.status} - - - - - - - - - ))} - -
+
+ + + + + + + + + + + + {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 ( + router.push(`/salary/runs/${run.id}`)} + > + + + + + + + ) + })} + +
{t('th_period')}{t('th_status')}{t('th_employees')}{t('th_gross')}{t('th_net')}
+ e.stopPropagation()} + > + {periodOf(run)} + + + + {run.status === 'booked' ? ( + {t('status_booked')} + ) : ( + + {STATUS_LABEL_KEYS[run.status] ? t(STATUS_LABEL_KEYS[run.status]) : run.status} + + )} + {inFlight && ( + + {t('run_payout_note', { date: formatDate(run.payment_date) })} + + )} + + + {employeeCount ?? ''} + + {formatCurrency(run.total_gross)} + + {formatCurrency(run.total_net)} +
+
)} - - +
+ )} ) } diff --git a/app/api/salary/runs/route.ts b/app/api/salary/runs/route.ts index 4fc58efc..9c6f5002 100644 --- a/app/api/salary/runs/route.ts +++ b/app/api/salary/runs/route.ts @@ -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) { diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index cc909dad..c9cbcd66 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -622,7 +622,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa />
@@ -677,7 +677,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa return ( <> {/* Desktop sidebar */} -