feat(home): concept Hem with Att göra + Fortsätt (UI migration PR 11) (#1132)
* feat(home): concept scene 14: greeting + Att gora/Fortsatt panes Hem becomes the founder-approved two-panel layout: serif time-of-day greeting with date and company, the Att gora worklist restyled to the concept pane (eyebrow header, h-rows with count chips, hover chevrons) and a new Fortsatt pane listing in-progress work derived purely from draft state (lib/worklist/resume: journal drafts, invoice drafts/unsent, mid-lifecycle salary runs; deadline boost, cap 3, tested). A completed flow can never render as a resume row by construction: only draft-state rows are fetched. KPI tiles, revenue/expense cards and the deadline/tax widgets leave the page per dev_docs/last_session_resume.md section 8, which also prunes their fetches (journal-line YTD aggregation, unpaid totals, deadlines): the page got faster. Banners, checklist, build-assistant hero and the Skatteverket nudge survive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(home): serif pane titles for Att gora and Fortsatt Founder feedback: the uppercase eyebrow headers read as a stray font. Both pane titles are now the Hedvig display serif (text-lg) over the hairline, matching the page's heading language; the band headers inside Att gora keep their small uppercase form as grouping devices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(home): Geist pane titles for Att gora and Fortsatt Founder call: the pane titles use the body sans (14px medium), not the display serif. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): Geist section headers + drop stale-transactions chip Founder feedback: pane/section headers (Att gora, Fortsatt, the reports groups Lopande/Bokslut/Skatt & moms etc) render in Geist sentence case instead of uppercase eyebrows or serif. The global h1-h3 display-font rule moves into @layer base so utility classes like font-sans can actually override it (unlayered element rules beat Tailwind's layered utilities: this was silently eating the override). Also removes the 'N aldre an 14 dagar' chip from the Bokfora transaktioner row and its stale-count plumbing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ui): continuous nav crossfade + floating slide-over entrance The rail/full nav states now stay mounted and crossfade past each other (the inactive layer absolute, faded, nudged sideways, inert) while the aside width animates: the switch reads as one continuous motion instead of a DOM swap. The detail slide-over floats in from the right edge (slide-in-from-right-full, 300ms decelerating curve) per the concept, with a quicker ease-in exit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): make transitions and enter/exit animations actually run Two silent app-wide animation killers found while chasing 'the nav still is not smooth': 1. The codebase uses the shadcn animate-in/out vocabulary everywhere but no animate plugin was ever installed: Tailwind v4 silently dropped every such class, so popovers, dialogs, menus and the slide-over all appeared instantly. globals.css now defines the exact subset in use (accEnter/accExit keyframes + var-driven utilities), plugin-free, composing with duration/ease via --tw-duration/--tw-ease and collapsing under prefers-reduced-motion. Dialog drops its bracket-variant slide classes (zoom+fade carries the entrance). 2. The scrollbar auto-hide block's universal '* { transition: scrollbar-color ... }' was unlayered, and unlayered rules beat Tailwind's layered transition-* utilities regardless of specificity: every width/margin/color transition in the app was dead. The rule now lives in @layer base. Verified: the aside animates 248->64 over 300ms and the slide-over runs accEnter at 0.3s with the decelerating curve. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): finish the rr-mask session-replay masking sweep Main's #1105 switched one amount cell from the no-op sensitive-field class to rr-mask (rrweb's built-in text-masking class). The reskinned tables introduced more sensitive-field cells; all 12 occurrences now use rr-mask so financial amounts are masked in session replays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4352862845
commit
be9d630347
@@ -139,7 +139,7 @@ export default function AssetsPage() {
|
||||
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
|
||||
{formatDate(asset.acquisition_date)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums sensitive-field')}>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
|
||||
{formatCurrency(Number(asset.acquisition_cost))}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground md:table-cell')}>
|
||||
|
||||
@@ -399,7 +399,7 @@ export default function InvoicesPage() {
|
||||
<td
|
||||
className={cn(
|
||||
TD_CLASS,
|
||||
'whitespace-nowrap text-right tabular-nums sensitive-field',
|
||||
'whitespace-nowrap text-right tabular-nums rr-mask',
|
||||
isCreditNote && 'text-destructive',
|
||||
)}
|
||||
title={
|
||||
|
||||
+20
-98
@@ -1,8 +1,8 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import DashboardContent from '@/components/dashboard/DashboardContent'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist'
|
||||
import type { Deadline, OnboardingProgress } from '@/types'
|
||||
import { listResumeItems } from '@/lib/worklist/resume'
|
||||
import type { OnboardingProgress } from '@/types'
|
||||
import {
|
||||
getDashboardAuthContext,
|
||||
getDashboardCompanyId,
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Home route = Översikt (DashboardContent). The agent chat has its own nav
|
||||
// entry at /chat, so / no longer forwards there. Initial setup is an optional,
|
||||
// persisted surface inside the dashboard and never replaces the overview.
|
||||
// Home route = Hem (concept scene 14): greeting + Att göra + Fortsätt.
|
||||
// The KPI/revenue/deadline widgets left the page (founder direction,
|
||||
// dev_docs/last_session_resume.md §8), which also pruned their fetches:
|
||||
// the journal-line YTD aggregation, unpaid-invoice totals and deadline
|
||||
// queries are gone and the page got faster.
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const [{ supabase, user }, companyId] = await Promise.all([
|
||||
@@ -30,12 +32,7 @@ export default async function DashboardPage() {
|
||||
redirect('/onboarding')
|
||||
}
|
||||
|
||||
// Fetch current year date boundaries
|
||||
const startOfYearStr = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]
|
||||
const startOfMonthStr = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0]
|
||||
const now = new Date()
|
||||
const today = now.toISOString().split('T')[0]
|
||||
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [
|
||||
@@ -43,41 +40,34 @@ export default async function DashboardPage() {
|
||||
{ count: customerCount },
|
||||
{ count: invoiceCount },
|
||||
{ count: transactionCount },
|
||||
{ data: journalLines },
|
||||
{ data: unpaidInvoices },
|
||||
{ data: bankConnections },
|
||||
{ data: deadlines },
|
||||
{ count: sieImportCount },
|
||||
{ count: staleUncategorizedCount },
|
||||
{ count: skatteverketTokenCount },
|
||||
{ data: profile },
|
||||
agentProfile,
|
||||
worklist,
|
||||
suggestedMatches,
|
||||
resumeItems,
|
||||
] = await Promise.all([
|
||||
getDashboardSettings(),
|
||||
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
supabase.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status, company_id)')
|
||||
.eq('journal_entry.status', 'posted')
|
||||
.eq('journal_entry.company_id', companyId)
|
||||
.gte('journal_entry.entry_date', startOfYearStr),
|
||||
supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status, ore_rounding').eq('company_id', companyId).in('status', ['sent', 'overdue']).is('credited_invoice_id', null),
|
||||
supabase.from('bank_connections').select('id, accounts_data, status, consent_expires, bank_name').eq('company_id', companyId).eq('status', 'active'),
|
||||
supabase.from('deadlines').select('*, customer:customers(id, name)').eq('company_id', companyId).eq('is_completed', false).is('dismissed_at', null)
|
||||
.or(`due_date.lt.${today},due_date.lte.${nextWeek}`).order('due_date', { ascending: true }),
|
||||
supabase.from('bank_connections').select('id, status, consent_expires, bank_name').eq('company_id', companyId).eq('status', 'active'),
|
||||
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
|
||||
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).eq('is_ignored', false).is('is_business', null).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]),
|
||||
// Skatteverket tokens are user-scoped (one BankID identity per user) but
|
||||
// carry the active company_id; either filter would work: we use user_id
|
||||
// because that's what the token-store reads/writes against.
|
||||
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
|
||||
// First name for the greeting.
|
||||
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
|
||||
getResolvedDashboardAgentProfile(),
|
||||
// Pending-work counts + suggested matches come from lib/worklist: the
|
||||
// same source as the sidebar badges, so the numbers can never diverge.
|
||||
getWorklistCounts(supabase, companyId),
|
||||
listSuggestedMatches(supabase, companyId, 5),
|
||||
// In-progress work for the Fortsätt pane: pure draft-state derivation.
|
||||
listResumeItems(supabase, companyId, now),
|
||||
])
|
||||
|
||||
// A FAILED settings read must not masquerade as "onboarding not done":
|
||||
@@ -104,68 +94,7 @@ export default async function DashboardPage() {
|
||||
hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0,
|
||||
}
|
||||
|
||||
// Calculate totals from journal entry lines using account classes
|
||||
const calculateTotals = (lines: typeof journalLines, fromDate: string) => {
|
||||
const filtered = (lines || []).filter((l) => {
|
||||
const entry = l.journal_entry as unknown as { entry_date: string; status: string }
|
||||
return entry.entry_date >= fromDate
|
||||
})
|
||||
|
||||
let revenue = 0
|
||||
let expenses = 0
|
||||
|
||||
for (const line of filtered) {
|
||||
const acct = line.account_number
|
||||
if (acct.startsWith('3')) {
|
||||
// Revenue: class 3, credit-normal accounts
|
||||
revenue += Math.round(((line.credit_amount || 0) - (line.debit_amount || 0)) * 100) / 100
|
||||
} else if (acct.startsWith('4') || acct.startsWith('5') || acct.startsWith('6') || acct.startsWith('7')) {
|
||||
// Expenses: classes 4-7, debit-normal accounts
|
||||
expenses += Math.round(((line.debit_amount || 0) - (line.credit_amount || 0)) * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
revenue = Math.round(revenue * 100) / 100
|
||||
expenses = Math.round(expenses * 100) / 100
|
||||
|
||||
return { income: revenue, expenses, net: Math.round((revenue - expenses) * 100) / 100 }
|
||||
}
|
||||
|
||||
const ytdTotals = calculateTotals(journalLines, startOfYearStr)
|
||||
const mtdTotals = calculateTotals(journalLines, startOfMonthStr)
|
||||
|
||||
// Mirror the per-invoice öresavrundning rule used on the invoice list/detail
|
||||
// pages: sum the displayed (rounded) SEK amount per invoice so the dashboard
|
||||
// total matches what the user sees on the invoice list when the setting is on.
|
||||
const unpaidTotal = (unpaidInvoices || []).reduce(
|
||||
(sum, inv) => sum + getDisplayTotal(
|
||||
{ total: Number(inv.total_sek || inv.total), currency: 'SEK', ore_rounding: inv.ore_rounding },
|
||||
settings,
|
||||
).displayed,
|
||||
0
|
||||
)
|
||||
|
||||
const unpaidVatTotal = (unpaidInvoices || []).reduce(
|
||||
(sum, inv) => sum + Number(inv.vat_amount_sek || inv.vat_amount || 0),
|
||||
0
|
||||
)
|
||||
|
||||
const overdueCount = (unpaidInvoices || []).filter(
|
||||
(inv) => inv.status === 'overdue'
|
||||
).length
|
||||
|
||||
let bankBalance: number | null = null
|
||||
if (bankConnections && bankConnections.length > 0) {
|
||||
const allBalances = bankConnections.flatMap(conn => {
|
||||
const accounts = conn.accounts_data as { balance: number }[] | null
|
||||
return accounts || []
|
||||
})
|
||||
if (allBalances.length > 0) {
|
||||
bankBalance = allBalances.reduce((sum, acc) => sum + (acc.balance || 0), 0)
|
||||
}
|
||||
}
|
||||
|
||||
const nowMs = new Date().getTime()
|
||||
const nowMs = now.getTime()
|
||||
const expiringBankConnections = (bankConnections || [])
|
||||
.filter(conn => {
|
||||
if (!conn.consent_expires) return false
|
||||
@@ -182,24 +111,17 @@ export default async function DashboardPage() {
|
||||
),
|
||||
}))
|
||||
|
||||
const userFirstName = profile?.full_name?.trim().split(/\s+/)[0] ?? null
|
||||
|
||||
return (
|
||||
<DashboardContent
|
||||
companyId={companyId}
|
||||
agentBuilt={agentBuilt}
|
||||
summary={{
|
||||
ytd: ytdTotals,
|
||||
mtd: mtdTotals,
|
||||
unpaidInvoicesCount: (unpaidInvoices || []).length,
|
||||
unpaidInvoicesTotal: unpaidTotal,
|
||||
unpaidVatTotal,
|
||||
overdueInvoicesCount: overdueCount,
|
||||
bankBalance,
|
||||
expiringBankConnections,
|
||||
deadlines: (deadlines || []) as Deadline[],
|
||||
staleUncategorizedCount: staleUncategorizedCount || 0,
|
||||
}}
|
||||
userFirstName={userFirstName}
|
||||
expiringBankConnections={expiringBankConnections}
|
||||
worklist={worklist}
|
||||
suggestedMatches={suggestedMatches}
|
||||
resumeItems={resumeItems}
|
||||
onboardingProgress={onboardingProgress}
|
||||
initialSetup={{
|
||||
path: settings.initial_setup_path ?? null,
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function EmployeesPage() {
|
||||
? t(EMPLOYMENT_LABEL_KEYS[emp.employment_type])
|
||||
: emp.employment_type}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums sensitive-field')}>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
|
||||
{emp.salary_type === 'hourly'
|
||||
? emp.hourly_rate ? `${formatCurrency(emp.hourly_rate)}${t('hourly_suffix')}` : '-'
|
||||
: emp.monthly_salary ? formatCurrency(emp.monthly_salary) : '-'}
|
||||
|
||||
@@ -216,10 +216,10 @@ export default function SalaryPage() {
|
||||
<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')}>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
|
||||
{formatCurrency(run.total_gross)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums sensitive-field')}>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
|
||||
{formatCurrency(run.total_net)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -320,13 +320,13 @@ export default function SupplierInvoicesPage() {
|
||||
{/* Belopp rounds like the detail page when the invoice's
|
||||
öresavrundning flag is on; "kvar att betala" stays
|
||||
öre-exact (it is the actual outstanding debt). */}
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums sensitive-field')}>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
|
||||
{formatCurrency(getDisplayTotal(
|
||||
{ total: inv.total, currency: inv.currency, ore_rounding: inv.ore_rounding },
|
||||
{ ore_rounding: false },
|
||||
).displayed, inv.currency)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums sensitive-field lg:table-cell')}>
|
||||
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums rr-mask lg:table-cell')}>
|
||||
{formatCurrency(inv.remaining_amount, inv.currency)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
|
||||
|
||||
+79
-9
@@ -201,11 +201,15 @@ body {
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.015em;
|
||||
font-optical-sizing: auto;
|
||||
/* Layered so utility classes (e.g. font-sans on a pane title) can override:
|
||||
unlayered element rules would beat Tailwind's layered utilities. */
|
||||
@layer base {
|
||||
h1, h2, h3 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.015em;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Number styling - tabular for alignment */
|
||||
@@ -303,6 +307,66 @@ h1, h2, h3 {
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* Enter/exit animation utilities (animate-in / animate-out vocabulary).
|
||||
* The codebase uses the shadcn/tailwindcss-animate classes but no animate
|
||||
* plugin was ever installed, so Tailwind v4 silently dropped them all:
|
||||
* every popover, dialog and slide-over appeared instantly. These implement
|
||||
* the exact subset in use, plugin-free. The duration and ease utilities
|
||||
* compose via Tailwind v4's --tw-duration and --tw-ease variables; the
|
||||
* global prefers-reduced-motion block collapses them like everything else.
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
@keyframes accEnter {
|
||||
from {
|
||||
opacity: var(--tw-enter-opacity, 1);
|
||||
transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0)
|
||||
scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), 1);
|
||||
}
|
||||
}
|
||||
@keyframes accExit {
|
||||
to {
|
||||
opacity: var(--tw-exit-opacity, 1);
|
||||
transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0)
|
||||
scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), 1);
|
||||
}
|
||||
}
|
||||
@utility animate-in {
|
||||
animation-name: accEnter;
|
||||
animation-duration: var(--tw-duration, 150ms);
|
||||
animation-timing-function: var(--tw-ease, cubic-bezier(0.32, 0.72, 0, 1));
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
@utility animate-out {
|
||||
animation-name: accExit;
|
||||
animation-duration: var(--tw-duration, 150ms);
|
||||
animation-timing-function: var(--tw-ease, cubic-bezier(0.32, 0.72, 0, 1));
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
@utility fade-in { --tw-enter-opacity: 0; }
|
||||
@utility fade-in-0 { --tw-enter-opacity: 0; }
|
||||
@utility fade-out { --tw-exit-opacity: 0; }
|
||||
@utility fade-out-0 { --tw-exit-opacity: 0; }
|
||||
@utility zoom-in-95 { --tw-enter-scale: 0.95; }
|
||||
@utility zoom-out-95 { --tw-exit-scale: 0.95; }
|
||||
@utility slide-in-from-top { --tw-enter-translate-y: -100%; }
|
||||
@utility slide-in-from-top-1 { --tw-enter-translate-y: -0.25rem; }
|
||||
@utility slide-in-from-top-2 { --tw-enter-translate-y: -0.5rem; }
|
||||
@utility slide-in-from-top-full { --tw-enter-translate-y: -100%; }
|
||||
@utility slide-in-from-bottom { --tw-enter-translate-y: 100%; }
|
||||
@utility slide-in-from-bottom-1 { --tw-enter-translate-y: 0.25rem; }
|
||||
@utility slide-in-from-bottom-2 { --tw-enter-translate-y: 0.5rem; }
|
||||
@utility slide-in-from-left { --tw-enter-translate-x: -100%; }
|
||||
@utility slide-in-from-left-1 { --tw-enter-translate-x: -0.25rem; }
|
||||
@utility slide-in-from-left-2 { --tw-enter-translate-x: -0.5rem; }
|
||||
@utility slide-in-from-right { --tw-enter-translate-x: 100%; }
|
||||
@utility slide-in-from-right-2 { --tw-enter-translate-x: 0.5rem; }
|
||||
@utility slide-in-from-right-full { --tw-enter-translate-x: 100%; }
|
||||
@utility slide-out-to-top { --tw-exit-translate-y: -100%; }
|
||||
@utility slide-out-to-bottom { --tw-exit-translate-y: 100%; }
|
||||
@utility slide-out-to-left { --tw-exit-translate-x: -100%; }
|
||||
@utility slide-out-to-right { --tw-exit-translate-x: 100%; }
|
||||
@utility slide-out-to-right-full { --tw-exit-translate-x: 100%; }
|
||||
|
||||
/* Staggered entrance animation */
|
||||
.stagger-enter > * {
|
||||
animation: slideUp var(--duration-slow) var(--ease-out) both;
|
||||
@@ -362,10 +426,16 @@ h1, h2, h3 {
|
||||
}
|
||||
|
||||
@supports (scrollbar-color: auto) {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 300ms ease-out;
|
||||
/* Layered: the universal transition must not beat Tailwind's layered
|
||||
transition-* utilities (unlayered rules win over layers regardless of
|
||||
specificity: this silently killed every width/margin/color transition
|
||||
in the app). scrollbar-color still animates via the base default. */
|
||||
@layer base {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 300ms ease-out;
|
||||
}
|
||||
}
|
||||
.is-scrolling {
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
|
||||
|
||||
@@ -1105,7 +1105,7 @@ export default function JournalEntryList() {
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums sensitive-field')}>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
|
||||
{formatCurrency(voucherTotal, 'SEK', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
@@ -53,7 +52,6 @@ interface AttGoraSectionProps {
|
||||
worklist: WorklistCounts
|
||||
suggestedMatches: SuggestedMatch[]
|
||||
expiringBankConnections?: ExpiringBankConnection[]
|
||||
staleUncategorizedCount: number
|
||||
}
|
||||
|
||||
interface WorklistRowProps {
|
||||
@@ -69,23 +67,29 @@ function WorklistRow({ href, icon: Icon, label, detail, count, badge }: Worklist
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/60 transition-colors duration-150"
|
||||
className="group flex w-full items-start gap-3 border-b border-border px-1 py-3.5 transition-colors duration-150 hover:bg-secondary/30"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{label}</p>
|
||||
{detail && <p className="text-xs text-muted-foreground mt-0.5 truncate">{detail}</p>}
|
||||
<span className="mt-px w-[18px] shrink-0 text-muted-foreground" aria-hidden>
|
||||
<Icon className="h-[15px] w-[15px]" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13.5px]">{label}</p>
|
||||
{detail && <p className="mt-0.5 truncate text-xs text-muted-foreground">{detail}</p>}
|
||||
</div>
|
||||
{badge}
|
||||
<span className="font-display text-base tabular-nums shrink-0">{count}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50 shrink-0" />
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2.5 pt-px">
|
||||
{badge}
|
||||
<Badge variant="secondary" className="font-normal tabular-nums">
|
||||
{count}
|
||||
</Badge>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100" />
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function BandHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="px-4 pt-4 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<p className="px-1 pt-5 pb-1 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground/80">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
@@ -95,7 +99,6 @@ export default function AttGoraSection({
|
||||
worklist,
|
||||
suggestedMatches,
|
||||
expiringBankConnections = [],
|
||||
staleUncategorizedCount,
|
||||
}: AttGoraSectionProps) {
|
||||
const t = useTranslations('dashboard')
|
||||
const { toast } = useToast()
|
||||
@@ -198,15 +201,15 @@ export default function AttGoraSection({
|
||||
|
||||
return (
|
||||
<section aria-label={t('att_gora_title')}>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="font-display text-lg">{t('att_gora_title')}</h2>
|
||||
<p className="text-sm text-muted-foreground tabular-nums" role="status" aria-live="polite">
|
||||
{/* Pane header: Geist title + quiet count over a hairline */}
|
||||
<div className="flex items-baseline justify-between border-b border-border px-1 pb-2.5">
|
||||
<h2 className="font-sans text-sm font-medium">{t('att_gora_title')}</h2>
|
||||
<p className="text-xs text-muted-foreground tabular-nums" role="status" aria-live="polite">
|
||||
{allClear ? t('all_done') : t('att_gora_left', { count: displayTotal })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div>
|
||||
{allClear ? (
|
||||
<EmptyState
|
||||
icon={CheckCircle2}
|
||||
@@ -219,20 +222,13 @@ export default function AttGoraSection({
|
||||
{bokforRows && (
|
||||
<div>
|
||||
<BandHeader>{t('band_bokfor')}</BandHeader>
|
||||
<div className="divide-y divide-border">
|
||||
<div>
|
||||
{counts.book_transaction > 0 && (
|
||||
<WorklistRow
|
||||
href="/transactions"
|
||||
icon={ArrowLeftRight}
|
||||
label={t('row_book_transactions')}
|
||||
count={counts.book_transaction}
|
||||
badge={
|
||||
staleUncategorizedCount > 0 ? (
|
||||
<Badge variant="warning" className="shrink-0">
|
||||
{t('row_book_transactions_stale', { count: staleUncategorizedCount })}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{matches.length > 0 && (
|
||||
@@ -319,7 +315,7 @@ export default function AttGoraSection({
|
||||
{granskaRows && (
|
||||
<div>
|
||||
<BandHeader>{t('band_granska')}</BandHeader>
|
||||
<div className="divide-y divide-border">
|
||||
<div>
|
||||
{counts.supplier_invoice_approval > 0 && (
|
||||
<WorklistRow
|
||||
href="/supplier-invoices"
|
||||
@@ -351,7 +347,7 @@ export default function AttGoraSection({
|
||||
{bevakaRows && (
|
||||
<div>
|
||||
<BandHeader>{t('band_bevaka')}</BandHeader>
|
||||
<div className="divide-y divide-border">
|
||||
<div>
|
||||
{counts.overdue_invoice > 0 && (
|
||||
<WorklistRow
|
||||
href="/invoices?status=unpaid"
|
||||
@@ -392,8 +388,7 @@ export default function AttGoraSection({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,46 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
|
||||
import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { useCapability, useCompany } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
||||
import AttGoraSection from '@/components/dashboard/AttGoraSection'
|
||||
import ResumePane from '@/components/dashboard/ResumePane'
|
||||
import BackupHealthBanner from '@/components/dashboard/BackupHealthBanner'
|
||||
import { SkatteverketPromoCard } from '@/components/dashboard/SkatteverketPromoCard'
|
||||
import {
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
ArrowRight,
|
||||
MessageCircle,
|
||||
} from 'lucide-react'
|
||||
import type { Deadline, InitialSetupState, OnboardingProgress } from '@/types'
|
||||
import { ArrowRight, MessageCircle } from 'lucide-react'
|
||||
import type { InitialSetupState, OnboardingProgress } from '@/types'
|
||||
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
|
||||
import { visibleWorklistTotalFrom } from '@/lib/worklist/visible-total'
|
||||
import type { ResumeItem } from '@/lib/worklist/resume'
|
||||
|
||||
interface DashboardContentProps {
|
||||
companyId: string
|
||||
summary: {
|
||||
ytd: { income: number; expenses: number; net: number }
|
||||
mtd: { income: number; expenses: number; net: number }
|
||||
unpaidInvoicesCount: number
|
||||
unpaidInvoicesTotal: number
|
||||
unpaidVatTotal: number
|
||||
overdueInvoicesCount: number
|
||||
bankBalance: number | null
|
||||
expiringBankConnections?: { id: string; bank_name: string; days_left: number }[]
|
||||
deadlines: Deadline[]
|
||||
staleUncategorizedCount: number
|
||||
}
|
||||
/** Signed-in user's first name for the greeting; null falls back to a
|
||||
* nameless greeting. */
|
||||
userFirstName?: string | null
|
||||
/** Expiring PSD2 consents (dashboard-only worklist extra). */
|
||||
expiringBankConnections?: { id: string; bank_name: string; days_left: number }[]
|
||||
/** Unified pending-work counts from lib/worklist: same source as the sidebar badges. */
|
||||
worklist: WorklistCounts
|
||||
/** High-confidence transaction↔invoice matches for inline one-click confirm. */
|
||||
suggestedMatches: SuggestedMatch[]
|
||||
/** In-progress work for the Fortsätt pane (lib/worklist/resume). */
|
||||
resumeItems: ResumeItem[]
|
||||
onboardingProgress?: OnboardingProgress
|
||||
initialSetup: InitialSetupState
|
||||
/**
|
||||
@@ -52,30 +41,56 @@ interface DashboardContentProps {
|
||||
agentBuilt?: boolean
|
||||
}
|
||||
|
||||
export default function DashboardContent({ companyId, summary, worklist, suggestedMatches, onboardingProgress, initialSetup, agentBuilt = true }: DashboardContentProps) {
|
||||
/**
|
||||
* Hem (concept scene 14): greeting, then the two panes side by side:
|
||||
* Att göra (obligations, lib/worklist) and Fortsätt (in-progress work,
|
||||
* lib/worklist/resume). KPI tiles, revenue/expense cards and the deadline/tax
|
||||
* widgets left the page (founder direction, dev_docs/last_session_resume.md
|
||||
* §8): the numbers live at /kpi and /reports, deadlines render as Bevaka rows.
|
||||
*/
|
||||
export default function DashboardContent({
|
||||
companyId,
|
||||
userFirstName,
|
||||
expiringBankConnections,
|
||||
worklist,
|
||||
suggestedMatches,
|
||||
resumeItems,
|
||||
onboardingProgress,
|
||||
initialSetup,
|
||||
agentBuilt = true,
|
||||
}: DashboardContentProps) {
|
||||
const t = useTranslations('dashboard')
|
||||
const hasAi = useCapability(CAPABILITY.ai)
|
||||
const { company } = useCompany()
|
||||
|
||||
const formatLargeNumber = (amount: number) => {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'decimal',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
// One number, one source (visibleWorklistTotal): the worklist total plus
|
||||
// expiring bank connections (dashboard-only), minus the hidden paid inbox row
|
||||
// for non-payers. Must match AttGoraSection's header off the same helper.
|
||||
const todoCount = visibleWorklistTotalFrom(
|
||||
worklist,
|
||||
hasAi,
|
||||
summary.expiringBankConnections?.length ?? 0,
|
||||
)
|
||||
// Time-of-day greeting (concept: "God morgon, Jakob."). Client-side clock
|
||||
// on purpose (the user's local morning, not the server's), captured once
|
||||
// so render stays pure.
|
||||
const [greetingNow] = useState(() => new Date())
|
||||
const hour = greetingNow.getHours()
|
||||
const greeting =
|
||||
hour < 10 ? t('greeting_morning') : hour < 17 ? t('greeting_day') : t('greeting_evening')
|
||||
const dateLine = new Intl.DateTimeFormat('sv-SE', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}).format(greetingNow)
|
||||
|
||||
return (
|
||||
<div className="stagger-enter space-y-8">
|
||||
<BackupHealthBanner />
|
||||
|
||||
{/* Greeting hero (concept scene 14) */}
|
||||
<section>
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">
|
||||
{userFirstName ? `${greeting}, ${userFirstName}.` : `${greeting}.`}
|
||||
</h1>
|
||||
<p className="mt-1.5 text-[13px] text-muted-foreground">
|
||||
{dateLine}
|
||||
{company?.name ? ` · ${company.name}` : ''}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<NewUserChecklist
|
||||
initialState={initialSetup}
|
||||
hasBookkeepingImported={!!onboardingProgress?.hasSIEImport}
|
||||
@@ -83,12 +98,10 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
hasSkatteverketConnected={!!onboardingProgress?.hasSkatteverketConnected}
|
||||
hasAgentBuilt={agentBuilt}
|
||||
/>
|
||||
|
||||
{/* Build-assistant hero: shown only until the company has a verified
|
||||
agent_profile, so existing/migrated users get a clear prompt instead
|
||||
of a full-screen onboarding takeover. Once the assistant is built the
|
||||
dashboard leads with the metrics + the unified "Att göra" worklist
|
||||
below; we deliberately drop a next-best-action hero here so the page
|
||||
has a single CTA surface instead of two that point at the same work. */}
|
||||
of a full-screen onboarding takeover. */}
|
||||
{!agentBuilt && (
|
||||
<section>
|
||||
{/* Non-payers keep seeing the hero (conversion surface) but it
|
||||
@@ -120,87 +133,20 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Key metrics: 4 compact cards */}
|
||||
<section>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('result')}</p>
|
||||
<p className={cn(
|
||||
'font-display text-xl tabular-nums leading-tight',
|
||||
summary.mtd.net >= 0 ? 'text-success' : 'text-destructive'
|
||||
)}>
|
||||
{formatLargeNumber(summary.mtd.net)}
|
||||
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatCurrency(summary.ytd.net)} {t('this_year_short')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Link href="/invoices?status=unpaid">
|
||||
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('to_be_paid')}</p>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||
</div>
|
||||
<p className="font-display text-xl tabular-nums leading-tight">
|
||||
{summary.unpaidInvoicesCount}
|
||||
{t('units') && <span className="text-sm ml-0.5 text-muted-foreground font-normal">{t('units')}</span>}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatCurrency(summary.unpaidInvoicesTotal)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{summary.bankBalance !== null ? (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('bank_balance')}</p>
|
||||
<p className="font-display text-xl tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.bankBalance)}
|
||||
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Link href="/import">
|
||||
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('bank_balance')}</p>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-primary">{t('connect_bank')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('todo')}</p>
|
||||
<div role="status" aria-live="polite">
|
||||
{todoCount > 0 ? (
|
||||
<p className="font-display text-xl tabular-nums leading-tight">
|
||||
{todoCount}
|
||||
{t('units') && <span className="text-sm ml-0.5 text-muted-foreground font-normal">{t('units')}</span>}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
<p className="text-sm font-medium text-success">{t('all_done')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
{/* The two panes (concept hem-grid). When nothing is in progress the
|
||||
right pane renders null and Att göra takes the full width. */}
|
||||
<div
|
||||
className={
|
||||
resumeItems.length > 0 ? 'grid items-start gap-x-6 gap-y-8 md:grid-cols-2' : undefined
|
||||
}
|
||||
>
|
||||
<AttGoraSection
|
||||
worklist={worklist}
|
||||
suggestedMatches={suggestedMatches}
|
||||
expiringBankConnections={expiringBankConnections}
|
||||
/>
|
||||
<ResumePane items={resumeItems} />
|
||||
</div>
|
||||
|
||||
{/* Connect-Skatteverket nudge for existing companies. Gated on
|
||||
agentBuilt so it never stacks under the build-assistant hero:
|
||||
@@ -211,64 +157,6 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
connected={!!onboardingProgress?.hasSkatteverketConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Att göra: the unified worklist. One section, every actionable item,
|
||||
same counts as the sidebar badges (lib/worklist). */}
|
||||
<AttGoraSection
|
||||
worklist={worklist}
|
||||
suggestedMatches={suggestedMatches}
|
||||
expiringBankConnections={summary.expiringBankConnections}
|
||||
staleUncategorizedCount={summary.staleUncategorizedCount}
|
||||
/>
|
||||
|
||||
{/* Result: revenue / expenses (always visible) */}
|
||||
<section>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-3">{t('revenue')}</p>
|
||||
<p className="font-display text-2xl tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.income)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('this_month')}</p>
|
||||
<div className="mt-4 pt-3 border-t border-border flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">{t('this_year_block')}</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-3">{t('expenses')}</p>
|
||||
<p className="font-display text-2xl tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.expenses)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('this_month')}</p>
|
||||
<div className="mt-4 pt-3 border-t border-border flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">{t('this_year_block')}</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Upcoming deadlines */}
|
||||
{summary.deadlines && summary.deadlines.length > 0 && (
|
||||
<section>
|
||||
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tax todo */}
|
||||
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
|
||||
<section>
|
||||
<TaxTodoWidget deadlines={summary.deadlines} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -722,12 +722,20 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
|
||||
{/* Nav items in their own scroll container so the user block
|
||||
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. */
|
||||
<div className="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden pt-1 pb-2">
|
||||
{/* Both states stay mounted and crossfade past each other while
|
||||
the aside width animates: no DOM swap, one continuous motion.
|
||||
The inactive layer is absolute (no layout), faded, nudged
|
||||
sideways and inert. */}
|
||||
<nav
|
||||
className="flex flex-col items-center gap-px px-2 animate-in fade-in slide-in-from-right-2 duration-200"
|
||||
aria-hidden={!collapsed}
|
||||
inert={!collapsed ? true : undefined}
|
||||
className={cn(
|
||||
'flex w-16 flex-col items-center gap-px px-2 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
|
||||
collapsed
|
||||
? 'opacity-100 translate-x-0'
|
||||
: 'pointer-events-none absolute inset-x-0 top-1 opacity-0 -translate-x-3',
|
||||
)}
|
||||
aria-label={tNav('main_navigation')}
|
||||
>
|
||||
{railItems.map((item) => renderRailItem(item))}
|
||||
@@ -757,9 +765,15 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
) : (
|
||||
<nav
|
||||
className="px-3 animate-in fade-in slide-in-from-left-2 duration-200"
|
||||
aria-hidden={collapsed}
|
||||
inert={collapsed ? true : undefined}
|
||||
className={cn(
|
||||
'w-[248px] px-3 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
|
||||
collapsed
|
||||
? 'pointer-events-none absolute inset-x-0 top-1 opacity-0 translate-x-3'
|
||||
: 'opacity-100 translate-x-0',
|
||||
)}
|
||||
aria-label={tNav('main_navigation')}
|
||||
>
|
||||
{/* Top section: flat, no header. Hem, Assistent. */}
|
||||
@@ -838,7 +852,6 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trial countdown touchpoint: the paywall is a lifecycle flow, not
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { BookOpen, ChevronRight, FileText, HandCoins } from 'lucide-react'
|
||||
import type { ResumeItem } from '@/lib/worklist/resume'
|
||||
|
||||
/**
|
||||
* "Fortsätt" (concept scene 14, right pane): in-progress work you can jump
|
||||
* back into, derived purely from draft state (lib/worklist/resume). Renders
|
||||
* null when empty: never a blank-but-present card.
|
||||
*/
|
||||
export default function ResumePane({ items }: { items: ResumeItem[] }) {
|
||||
const t = useTranslations('dashboard')
|
||||
|
||||
// Captured once: render must stay pure and the labels stable per mount.
|
||||
const [nowMs] = useState(() => Date.now())
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
const relativeLabel = (iso: string): string => {
|
||||
const days = Math.floor((nowMs - new Date(iso).getTime()) / 86_400_000)
|
||||
if (days <= 0) return t('rel_today')
|
||||
if (days === 1) return t('rel_yesterday')
|
||||
return t('rel_days', { count: days })
|
||||
}
|
||||
|
||||
const rowFor = (item: ResumeItem) => {
|
||||
switch (item.kind) {
|
||||
case 'invoice_draft':
|
||||
return {
|
||||
icon: FileText,
|
||||
title: t('resume_invoice_draft', { customer: item.context ?? '' }),
|
||||
sub: [
|
||||
item.amount != null ? formatCurrency(item.amount, item.currency ?? 'SEK') : null,
|
||||
t('resume_edited', { when: relativeLabel(item.updated_at) }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
}
|
||||
case 'invoice_unsent':
|
||||
return {
|
||||
icon: FileText,
|
||||
title: t('resume_invoice_unsent', { number: item.number ?? '' }),
|
||||
sub: [
|
||||
item.context,
|
||||
item.amount != null ? formatCurrency(item.amount, item.currency ?? 'SEK') : null,
|
||||
t('resume_edited', { when: relativeLabel(item.updated_at) }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
}
|
||||
case 'salary_run': {
|
||||
const key =
|
||||
item.salaryStatus === 'review'
|
||||
? 'resume_salary_review'
|
||||
: item.salaryStatus === 'approved'
|
||||
? 'resume_salary_approved'
|
||||
: item.salaryStatus === 'paid'
|
||||
? 'resume_salary_paid'
|
||||
: 'resume_salary_draft'
|
||||
return {
|
||||
icon: HandCoins,
|
||||
title: t(key, { period: item.context ?? '' }),
|
||||
sub: t('resume_edited', { when: relativeLabel(item.updated_at) }),
|
||||
}
|
||||
}
|
||||
case 'journal_draft':
|
||||
default:
|
||||
return {
|
||||
icon: BookOpen,
|
||||
title: t('resume_journal_draft'),
|
||||
sub: [item.context, t('resume_edited', { when: relativeLabel(item.updated_at) })]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label={t('resume_title')}>
|
||||
{/* Pane header: Geist title over a hairline */}
|
||||
<div className="flex items-baseline justify-between border-b border-border px-1 pb-2.5">
|
||||
<h2 className="font-sans text-sm font-medium">{t('resume_title')}</h2>
|
||||
</div>
|
||||
<div>
|
||||
{items.map((item) => {
|
||||
const row = rowFor(item)
|
||||
const Icon = row.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.ref}
|
||||
href={item.href}
|
||||
className="group flex w-full items-start gap-3 border-b border-border px-1 py-3.5 transition-colors duration-150 hover:bg-secondary/30"
|
||||
>
|
||||
<span className="mt-px w-[18px] shrink-0 text-muted-foreground" aria-hidden>
|
||||
<Icon className="h-[15px] w-[15px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13.5px]">{row.title}</span>
|
||||
{row.sub && (
|
||||
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
|
||||
{row.sub}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2.5 pt-px">
|
||||
{item.late && (
|
||||
<Badge variant="warning" className="font-normal">
|
||||
{t('resume_late')}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function RecentReportsShelf({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<h2 className="font-sans text-sm font-medium">
|
||||
{t('recent_heading')}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -35,7 +35,7 @@ export function ReportLibrary({
|
||||
<div className="space-y-8">
|
||||
{sections.map((section) => (
|
||||
<div key={section.category} className="space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<h2 className="font-sans text-sm font-medium">
|
||||
{t(section.labelKey)}
|
||||
</h2>
|
||||
<DataList>
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function SkattekontoInboxCard({
|
||||
<td
|
||||
className={cn(
|
||||
TD_CLASS,
|
||||
'whitespace-nowrap text-right tabular-nums sensitive-field',
|
||||
'whitespace-nowrap text-right tabular-nums rr-mask',
|
||||
isIncome && 'text-success',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -330,7 +330,7 @@ function BankHistoryRow({
|
||||
<td
|
||||
className={cn(
|
||||
TD_CLASS,
|
||||
'whitespace-nowrap text-right tabular-nums sensitive-field',
|
||||
'whitespace-nowrap text-right tabular-nums rr-mask',
|
||||
isIncome && 'text-success',
|
||||
)}
|
||||
title={
|
||||
@@ -469,7 +469,7 @@ function SkattekontoHistoryRow({
|
||||
<td
|
||||
className={cn(
|
||||
TD_CLASS,
|
||||
'whitespace-nowrap text-right tabular-nums sensitive-field',
|
||||
'whitespace-nowrap text-right tabular-nums rr-mask',
|
||||
isIncome && 'text-success',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -302,7 +302,7 @@ export default function TransactionInboxCard({
|
||||
<td
|
||||
className={cn(
|
||||
TD_CLASS,
|
||||
'whitespace-nowrap text-right tabular-nums sensitive-field',
|
||||
'whitespace-nowrap text-right tabular-nums rr-mask',
|
||||
isIncome && 'text-success',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -37,7 +37,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-[var(--shadow-md)] duration-200 max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-visible data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-[var(--shadow-md)] duration-200 max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-visible data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -32,8 +32,8 @@ const SlideOverContent = React.forwardRef<
|
||||
className={cn(
|
||||
'fixed top-[18px] right-[18px] bottom-[18px] z-50 flex w-[480px] max-w-[calc(100vw-36px)] flex-col',
|
||||
'rounded-xl border border-border bg-background shadow-[var(--shadow-lg)]',
|
||||
'duration-200 data-[state=open]:animate-in data-[state=open]:slide-in-from-right-8 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right-8 data-[state=closed]:fade-out-0',
|
||||
'data-[state=open]:animate-in data-[state=open]:slide-in-from-right-full data-[state=open]:fade-in-0 data-[state=open]:duration-300 data-[state=open]:ease-[cubic-bezier(0.32,0.72,0,1)]',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right-full data-[state=closed]:fade-out-0 data-[state=closed]:duration-200 data-[state=closed]:ease-in',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
mergeResumeItems,
|
||||
isSalaryRunLate,
|
||||
RESUME_MAX_ROWS,
|
||||
type ResumeItem,
|
||||
} from '../resume'
|
||||
|
||||
function item(overrides: Partial<ResumeItem>): ResumeItem {
|
||||
return {
|
||||
kind: 'journal_draft',
|
||||
ref: `journal:${Math.abs(overrides.updated_at?.length ?? 1)}`,
|
||||
href: '/bookkeeping/x',
|
||||
context: null,
|
||||
updated_at: '2026-07-01T10:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('mergeResumeItems', () => {
|
||||
it('orders by updated_at desc', () => {
|
||||
const merged = mergeResumeItems([
|
||||
item({ ref: 'a', updated_at: '2026-07-01T10:00:00Z' }),
|
||||
item({ ref: 'b', updated_at: '2026-07-03T10:00:00Z' }),
|
||||
item({ ref: 'c', updated_at: '2026-07-02T10:00:00Z' }),
|
||||
])
|
||||
expect(merged.map((i) => i.ref)).toEqual(['b', 'c', 'a'])
|
||||
})
|
||||
|
||||
it('boosts late items above newer non-late items', () => {
|
||||
const merged = mergeResumeItems([
|
||||
item({ ref: 'fresh', updated_at: '2026-07-20T10:00:00Z' }),
|
||||
item({ ref: 'late-old', late: true, updated_at: '2026-05-01T10:00:00Z' }),
|
||||
])
|
||||
expect(merged[0].ref).toBe('late-old')
|
||||
})
|
||||
|
||||
it('caps at RESUME_MAX_ROWS', () => {
|
||||
const many = Array.from({ length: 7 }, (_, i) =>
|
||||
item({ ref: `r${i}`, updated_at: `2026-07-0${i + 1}T10:00:00Z` }),
|
||||
)
|
||||
expect(mergeResumeItems(many)).toHaveLength(RESUME_MAX_ROWS)
|
||||
})
|
||||
|
||||
it('keeps late-first ordering stable within groups', () => {
|
||||
const merged = mergeResumeItems([
|
||||
item({ ref: 'late-new', late: true, updated_at: '2026-07-10T10:00:00Z' }),
|
||||
item({ ref: 'late-old', late: true, updated_at: '2026-06-10T10:00:00Z' }),
|
||||
item({ ref: 'plain', updated_at: '2026-07-22T10:00:00Z' }),
|
||||
])
|
||||
expect(merged.map((i) => i.ref)).toEqual(['late-new', 'late-old', 'plain'])
|
||||
})
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const input = [
|
||||
item({ ref: 'a', updated_at: '2026-07-01T10:00:00Z' }),
|
||||
item({ ref: 'b', updated_at: '2026-07-02T10:00:00Z' }),
|
||||
]
|
||||
const before = [...input]
|
||||
mergeResumeItems(input)
|
||||
expect(input).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSalaryRunLate', () => {
|
||||
const now = new Date('2026-07-23T12:00:00Z')
|
||||
|
||||
it('flags a run two months past its period', () => {
|
||||
expect(isSalaryRunLate(2026, 5, now)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not flag last month', () => {
|
||||
expect(isSalaryRunLate(2026, 6, now)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag the current period', () => {
|
||||
expect(isSalaryRunLate(2026, 7, now)).toBe(false)
|
||||
})
|
||||
|
||||
it('handles year boundaries', () => {
|
||||
expect(isSalaryRunLate(2025, 12, now)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* Resume list ("Fortsätt") for the homepage: in-progress work derived purely
|
||||
* from draft/mid-lifecycle state (dev_docs/last_session_resume.md §6).
|
||||
* No event log, no presence table: a completed flow can never render here
|
||||
* because only draft-state rows are ever fetched (the reliability invariant).
|
||||
*
|
||||
* Lives in lib/worklist (owns pending-work predicates) but is deliberately
|
||||
* NOT part of WorklistCounts.total: Att göra = obligations the system
|
||||
* imposes; Fortsätt = work the user started and left. An item renders in
|
||||
* exactly one surface.
|
||||
*/
|
||||
|
||||
export type ResumeItemKind =
|
||||
| 'journal_draft'
|
||||
| 'invoice_draft'
|
||||
| 'invoice_unsent'
|
||||
| 'salary_run'
|
||||
|
||||
export type ResumeSalaryStatus = 'draft' | 'review' | 'approved' | 'paid'
|
||||
|
||||
export interface ResumeItem {
|
||||
kind: ResumeItemKind
|
||||
/** Stable reference, e.g. 'invoice:<uuid>'. */
|
||||
ref: string
|
||||
href: string
|
||||
/** Free-text context: verifikat description, customer name, or period. */
|
||||
context: string | null
|
||||
/** Invoice number (unsent invoices). */
|
||||
number?: string | null
|
||||
amount?: number | null
|
||||
currency?: string | null
|
||||
salaryStatus?: ResumeSalaryStatus
|
||||
/** Deadline boost: unsent invoices and stale salary runs outrank newer
|
||||
* trivial drafts (Iqbal & Horvitz: deadline-tied tasks matter more). */
|
||||
late?: boolean
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const RESUME_MAX_ROWS = 3
|
||||
|
||||
/** A salary run whose period lies >= this many months back is "late". */
|
||||
const SALARY_LATE_MONTHS = 2
|
||||
|
||||
export function isSalaryRunLate(
|
||||
periodYear: number,
|
||||
periodMonth: number,
|
||||
now: Date,
|
||||
): boolean {
|
||||
const monthsBehind =
|
||||
(now.getFullYear() - periodYear) * 12 + (now.getMonth() + 1 - periodMonth)
|
||||
return monthsBehind >= SALARY_LATE_MONTHS
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure ordering + cap: late items first (deadline boost), then most recently
|
||||
* touched. Deterministic and explainable: no scoring, no ML.
|
||||
*/
|
||||
export function mergeResumeItems(items: ResumeItem[]): ResumeItem[] {
|
||||
return [...items]
|
||||
.sort((a, b) => {
|
||||
if (Boolean(a.late) !== Boolean(b.late)) return a.late ? -1 : 1
|
||||
return b.updated_at.localeCompare(a.updated_at)
|
||||
})
|
||||
.slice(0, RESUME_MAX_ROWS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch resume candidates for a company. Every query soft-fails to empty:
|
||||
* the worst case is a missing pane, never a broken homepage.
|
||||
*/
|
||||
export async function listResumeItems(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<ResumeItem[]> {
|
||||
const [journalRes, invoiceRes, salaryRes] = await Promise.all([
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id, description, updated_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'draft')
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(RESUME_MAX_ROWS + 1)
|
||||
.then((r) => r, () => ({ data: null })),
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, total, currency, updated_at, customer:customers(name)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'draft')
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(RESUME_MAX_ROWS + 1)
|
||||
.then((r) => r, () => ({ data: null })),
|
||||
supabase
|
||||
.from('salary_runs')
|
||||
.select('id, period_year, period_month, status, updated_at')
|
||||
.eq('company_id', companyId)
|
||||
// Enumerated on purpose: `!= 'booked'` would resurface 'corrected'.
|
||||
.in('status', ['draft', 'review', 'approved', 'paid'])
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(RESUME_MAX_ROWS + 1)
|
||||
.then((r) => r, () => ({ data: null })),
|
||||
])
|
||||
|
||||
const items: ResumeItem[] = []
|
||||
|
||||
for (const row of (journalRes.data ?? []) as Array<{
|
||||
id: string
|
||||
description: string | null
|
||||
updated_at: string
|
||||
}>) {
|
||||
items.push({
|
||||
kind: 'journal_draft',
|
||||
ref: `journal:${row.id}`,
|
||||
href: `/bookkeeping/${row.id}`,
|
||||
context: row.description,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
for (const row of (invoiceRes.data ?? []) as Array<{
|
||||
id: string
|
||||
invoice_number: string | null
|
||||
total: number | null
|
||||
currency: string | null
|
||||
updated_at: string
|
||||
customer: { name: string } | { name: string }[] | null
|
||||
}>) {
|
||||
const customer = Array.isArray(row.customer) ? row.customer[0] : row.customer
|
||||
const unsent = !!row.invoice_number
|
||||
items.push({
|
||||
kind: unsent ? 'invoice_unsent' : 'invoice_draft',
|
||||
ref: `invoice:${row.id}`,
|
||||
href: `/invoices/${row.id}`,
|
||||
context: customer?.name ?? null,
|
||||
number: row.invoice_number,
|
||||
amount: row.total,
|
||||
currency: row.currency,
|
||||
late: unsent,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
for (const row of (salaryRes.data ?? []) as Array<{
|
||||
id: string
|
||||
period_year: number
|
||||
period_month: number
|
||||
status: ResumeSalaryStatus
|
||||
updated_at: string
|
||||
}>) {
|
||||
items.push({
|
||||
kind: 'salary_run',
|
||||
ref: `salary:${row.id}`,
|
||||
href: `/salary/runs/${row.id}`,
|
||||
context: `${row.period_year}-${String(row.period_month).padStart(2, '0')}`,
|
||||
salaryStatus: row.status,
|
||||
late: isSalaryRunLate(row.period_year, row.period_month, now),
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
return mergeResumeItems(items)
|
||||
}
|
||||
+17
-1
@@ -5115,7 +5115,23 @@
|
||||
"suggested_view": "View transaction",
|
||||
"suggested_confirmed_toast": "Match recorded",
|
||||
"suggested_failed_toast": "Match failed",
|
||||
"row_deadlines": "VAT and tax deadlines"
|
||||
"row_deadlines": "VAT and tax deadlines",
|
||||
"greeting_morning": "Good morning",
|
||||
"greeting_day": "Good day",
|
||||
"greeting_evening": "Good evening",
|
||||
"resume_title": "Continue",
|
||||
"resume_invoice_draft": "Draft: invoice to {customer}",
|
||||
"resume_invoice_unsent": "Send invoice {number}",
|
||||
"resume_journal_draft": "Finish journal entry draft",
|
||||
"resume_salary_draft": "Calculate payroll run {period}",
|
||||
"resume_salary_review": "Review payroll run {period}",
|
||||
"resume_salary_approved": "Mark payroll run {period} paid",
|
||||
"resume_salary_paid": "Book payroll run {period}",
|
||||
"resume_edited": "edited {when}",
|
||||
"resume_late": "Late",
|
||||
"rel_today": "today",
|
||||
"rel_yesterday": "yesterday",
|
||||
"rel_days": "{count} days ago"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Reports",
|
||||
|
||||
+17
-1
@@ -5115,7 +5115,23 @@
|
||||
"suggested_view": "Visa transaktionen",
|
||||
"suggested_confirmed_toast": "Matchning bokförd",
|
||||
"suggested_failed_toast": "Matchningen misslyckades",
|
||||
"row_deadlines": "Moms- och skattedeadlines"
|
||||
"row_deadlines": "Moms- och skattedeadlines",
|
||||
"greeting_morning": "God morgon",
|
||||
"greeting_day": "God dag",
|
||||
"greeting_evening": "God kväll",
|
||||
"resume_title": "Fortsätt",
|
||||
"resume_invoice_draft": "Utkast: faktura till {customer}",
|
||||
"resume_invoice_unsent": "Skicka faktura {number}",
|
||||
"resume_journal_draft": "Slutför verifikatutkast",
|
||||
"resume_salary_draft": "Beräkna lönekörning {period}",
|
||||
"resume_salary_review": "Granska lönekörning {period}",
|
||||
"resume_salary_approved": "Markera lönekörning {period} utbetald",
|
||||
"resume_salary_paid": "Bokför lönekörning {period}",
|
||||
"resume_edited": "ändrad {when}",
|
||||
"resume_late": "Försenad",
|
||||
"rel_today": "i dag",
|
||||
"rel_yesterday": "i går",
|
||||
"rel_days": "för {count} dagar sedan"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Rapporter",
|
||||
|
||||
Reference in New Issue
Block a user