feat(empty-states): startkort on six pages with strata imagery (#1603)
Replace the true-empty states on Kundfakturor, Transaktioner, Underlag, Loner, Bokforing and Skattekonto with StartCard: a self-contained dark hero (image-derived ground baked into the strata render, white primary CTA) that says what the page can do instead of what is missing. Primary CTAs lead with the connect/setup action per page (bank via PSD2 deep link, mailboxes, Skatteverket, migration import); filtered/search empty states and viewer fallbacks keep the old compact states. Design signed off in the Startkort prototype iterations 2026-08-13. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -952,3 +952,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-13] PR #1598, compliance findings closed with the rollout after the Swedish accounting review escalated them from follow-up to fix-with-rollout: (a) runReconciliation's >= 0.9 auto-apply now writes 'matched' to payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); (b) the three match-route storno-conflict branches no longer storno-reverse a reconciliation-linked verifikat: a reconciliation link points at an independent verifikat that may evidence other affarshandelser, and a wholesale reversal is an over-broad rattelse (BFL 5 kap 5 §). The detach is DEFERRED (round 2, CodeRabbit): nothing is persisted up front; the final transaction update overwrites the pointer and clears reconciliation_method in the same write, so a failure anywhere in the match flow leaves the existing link intact, and the release is logged as 'unmatched' after the commit.
|
||||
[2026-08-13] PR #1598, CodeRabbit findings: confirm-suggestions maxDuration 300; lookbackTouched on the migrator nudge buttons; persistSuggestions on main's post-backfill sweep; sie_sweep stamp errors logged; sandbox keeps the CSV CTA (file import works there); payment_match_log CHECK swap now NOT VALID + VALIDATE (no table-scan under ACCESS EXCLUSIVE); every logMatchEvent call awaited (serverless can freeze unawaited work).
|
||||
[2026-08-13] Historical audit gap quantified on prod (read-only): 762 manual-method links across 52 companies since 2026-03-23 have no payment_match_log row (upper bound: includes linked_to_existing_voucher drops AND older unlogged manual paths). Not backfillable (the inserts never landed); the links themselves are intact on transactions. Recorded here as the explicit ops note the compliance review asked for.
|
||||
[2026-08-13] Startkort empty states use inline gradient scrims over their strata images: this is imagery treatment inside a hero surface, not card chrome, so the "no bg-gradient on cards" rule deliberately does not apply there (and nowhere else).
|
||||
[2026-08-13] Startkort webp assets (public/startkort/) are rendered outputs from the strata-engine in the CRM workspace, with per-file sources and flags recorded in components/dashboard/startkort-assets.ts; regenerate there, never edit the webp files by hand.
|
||||
|
||||
@@ -5,6 +5,7 @@ import dynamic from 'next/dynamic'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
|
||||
import { StartCard } from '@/components/dashboard/StartCard'
|
||||
import { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog'
|
||||
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
|
||||
@@ -54,6 +55,7 @@ export default function BookkeepingPage() {
|
||||
const [isLoadingCopy, setIsLoadingCopy] = useState(false)
|
||||
const [nextVoucher, setNextVoucher] = useState<NextVoucher | null>(null)
|
||||
const t = useTranslations('bookkeeping')
|
||||
const tStart = useTranslations('start_cards')
|
||||
const { openAgentSheet } = useAgentSheet()
|
||||
const { uiState, loaded: uiStateLoaded } = useUiState()
|
||||
|
||||
@@ -186,7 +188,53 @@ export default function BookkeepingPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<JournalEntryList key={refreshKey} />
|
||||
<JournalEntryList
|
||||
key={refreshKey}
|
||||
pristineSlot={
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<StartCard
|
||||
card="ledger"
|
||||
layout="bleed-left"
|
||||
title={tStart('bookkeeping_title')}
|
||||
body={tStart('bookkeeping_body')}
|
||||
primary={{ label: tStart('bookkeeping_primary'), href: '/import?mode=migration' }}
|
||||
secondary={{ label: tStart('bookkeeping_secondary'), href: '/import?mode=sie' }}
|
||||
/>
|
||||
{/* The split button's three create modes, laid out as cards so the
|
||||
pristine page shows what the ledger can do instead of a bare table. */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
['mall', () => setShowTemplateDialog(true)],
|
||||
[
|
||||
'assistent',
|
||||
() => openAgentSheet({ intentId: 'verifikation.draft', contextRef: 'verifikation:new' }),
|
||||
],
|
||||
[
|
||||
'tomt',
|
||||
() => {
|
||||
setCopyPrefill(null)
|
||||
setShowNewEntry(true)
|
||||
},
|
||||
],
|
||||
] as const
|
||||
).map(([mode, onClick]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="rounded-lg border border-border bg-background p-4 text-left transition-colors duration-150 hover:bg-secondary/35 active:bg-secondary/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="text-[13px] font-medium">{tStart(`bookkeeping_mini_${mode}_title`)}</div>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
{tStart(`bookkeeping_mini_${mode}_body`)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{showTemplateDialog && (
|
||||
<TemplateBookDialog
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
FileInput,
|
||||
FileDown,
|
||||
} from 'lucide-react'
|
||||
import { EmptyInvoices } from '@/components/ui/empty-state'
|
||||
import { StartCard } from '@/components/dashboard/StartCard'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { FiscalPeriod, Invoice } from '@/types'
|
||||
@@ -223,6 +223,7 @@ export default function InvoicesPage() {
|
||||
const supabase = createClient()
|
||||
const t = useTranslations('invoices')
|
||||
const tCommon = useTranslations('common')
|
||||
const tStart = useTranslations('start_cards')
|
||||
const { uiState, loaded: uiStateLoaded } = useUiState()
|
||||
|
||||
// The "Ny faktura" modal is driven by the URL (?new=1) so every entry point
|
||||
@@ -695,7 +696,16 @@ export default function InvoicesPage() {
|
||||
description={t('no_search_results_description', { term: searchTerm })}
|
||||
/>
|
||||
) : invoices.length === 0 ? (
|
||||
<EmptyInvoices onAction={openNewInvoice} />
|
||||
<div className="animate-fade-in">
|
||||
<StartCard
|
||||
card="venice"
|
||||
layout="center"
|
||||
title={tStart('invoices_title')}
|
||||
body={tStart('invoices_body')}
|
||||
primary={{ label: tStart('invoices_primary'), href: '/import?mode=migration' }}
|
||||
secondary={{ label: tStart('invoices_secondary'), onClick: openNewInvoice }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DataListEmpty
|
||||
icon={<ReceiptText className="h-6 w-6" />}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { StartCard } from '@/components/dashboard/StartCard'
|
||||
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'
|
||||
@@ -53,6 +54,7 @@ export default function SalaryPage() {
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('salary')
|
||||
const tStart = useTranslations('start_cards')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [runsRes, empRes] = await Promise.all([
|
||||
@@ -146,23 +148,37 @@ export default function SalaryPage() {
|
||||
{header}
|
||||
|
||||
{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}
|
||||
/>
|
||||
canWrite ? (
|
||||
<div className="animate-fade-in">
|
||||
<StartCard
|
||||
card="stopwatch"
|
||||
layout="side-right"
|
||||
eyebrow={tStart('salary_eyebrow_start')}
|
||||
title={tStart('salary_title')}
|
||||
body={tStart('salary_body_no_employees')}
|
||||
primary={{ label: tStart('salary_primary_add'), href: '/salary/employees/new' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon={Users} title={t('onboarding_title')} description={t('onboarding_description')} />
|
||||
)
|
||||
) : (
|
||||
<div>
|
||||
{runs.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={HandCoins}
|
||||
title={t('empty_runs_title')}
|
||||
description={t('empty_runs_description')}
|
||||
actionLabel={canWrite ? t('start_run') : undefined}
|
||||
onAction={canWrite ? startRun : undefined}
|
||||
/>
|
||||
canWrite ? (
|
||||
<div className="animate-fade-in">
|
||||
<StartCard
|
||||
card="stopwatch"
|
||||
layout="side-right"
|
||||
eyebrow={tStart('salary_eyebrow_next')}
|
||||
title={tStart('salary_title')}
|
||||
body={tStart('salary_body_ready', { count: employees.length })}
|
||||
primary={{ label: tStart('salary_primary_run'), onClick: startRun }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon={HandCoins} title={t('empty_runs_title')} description={t('empty_runs_description')} />
|
||||
)
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PageHeader } from '@/components/ui/page-header'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { StartCard } from '@/components/dashboard/StartCard'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
TH_CLASS,
|
||||
@@ -42,8 +43,6 @@ import { rowsNeedingInterestDate } from '@/lib/skatteverket/interest-period'
|
||||
import {
|
||||
AlertCircle,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Landmark,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
@@ -89,6 +88,7 @@ interface MatchCandidate {
|
||||
export default function SkattekontoPage() {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('skattekonto')
|
||||
const tStart = useTranslations('start_cards')
|
||||
const hasSkvCapability = useCapability(CAPABILITY.skatteverket)
|
||||
const [showPayment, setShowPayment] = useState(false)
|
||||
const [saldo, setSaldo] = useState<SaldoEnvelope | null>(null)
|
||||
@@ -374,18 +374,15 @@ export default function SkattekontoPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader title="Skattekonto" help={helpNode} />
|
||||
<EmptyState
|
||||
icon={Landmark}
|
||||
title="Skatteverket är inte anslutet"
|
||||
description="För att se saldo och transaktioner på skattekontot behöver du ansluta med BankID i inställningarna."
|
||||
>
|
||||
<Button asChild>
|
||||
<Link href="/settings/tax">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Anslut Skatteverket
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
<div className="animate-fade-in">
|
||||
<StartCard
|
||||
card="abacus"
|
||||
layout="side-right"
|
||||
title={tStart('skattekonto_title')}
|
||||
body={tStart('skattekonto_body')}
|
||||
primary={{ label: tStart('skattekonto_primary'), href: '/settings/tax' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment, useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Fragment, useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
@@ -189,7 +189,7 @@ const PAGE_SIZE_VALUES = new Set<PageSizeChoice>(['20', '50', '100', 'all'])
|
||||
// Sentinel limit sent for "Alla". The route clamps this to its own MAX_LIMIT.
|
||||
const ALL_PAGE_SIZE = 100000
|
||||
|
||||
export default function JournalEntryList() {
|
||||
export default function JournalEntryList({ pristineSlot }: { pristineSlot?: ReactNode } = {}) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
@@ -932,6 +932,9 @@ export default function JournalEntryList() {
|
||||
// drafts view) must fall through to the main render below so the
|
||||
// Verifikat/Utkast toggle stays reachable.
|
||||
if (!loading && entries.length === 0 && !loadFailed && !hasActiveFilters && listMode === 'committed' && draftCount === 0) {
|
||||
if (pristineSlot) {
|
||||
return <>{pristineSlot}</>
|
||||
}
|
||||
return (
|
||||
<DataList className="stagger-enter">
|
||||
<DataListEmpty
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { START_CARDS, type StartCardName } from './startkort-assets'
|
||||
|
||||
// The start card is a self-contained dark surface: like LedgerGraph it carries
|
||||
// its own palette in both themes, because the strata image bakes its ground
|
||||
// color into the pixels. Warm white type and a white primary pill sit on top.
|
||||
// Gradients here are image scrims inside a hero surface, not card chrome
|
||||
// (see DECISIONS.md 2026-08-13), and rounded-xl is the hero-surface allowance.
|
||||
const TITLE_COLOR = '#FAF8F1'
|
||||
const BODY_COLOR = 'rgba(247, 244, 236, 0.72)'
|
||||
const EYEBROW_COLOR = 'rgba(247, 244, 236, 0.52)'
|
||||
|
||||
interface StartCardAction {
|
||||
label: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
interface StartCardProps {
|
||||
card: StartCardName
|
||||
/** 'side-right': image on the right half. 'bleed-left': full-bleed image,
|
||||
* text over a scrim on the left. 'center': full-bleed, centered text. */
|
||||
layout: 'side-right' | 'bleed-left' | 'center'
|
||||
title: string
|
||||
body: string
|
||||
eyebrow?: string
|
||||
primary: StartCardAction
|
||||
secondary?: StartCardAction
|
||||
/** Floating channel badges over the right half (Underlag). */
|
||||
floatIcons?: boolean
|
||||
/** Tighter type for narrow containers (the inbox preview pane). */
|
||||
dense?: boolean
|
||||
onDismiss?: () => void
|
||||
dismissLabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function ActionButton({ action, kind }: { action: StartCardAction; kind: 'primary' | 'secondary' }) {
|
||||
const styles =
|
||||
kind === 'primary'
|
||||
? 'bg-white text-[#1A1410] hover:bg-white/90 active:bg-white/90 focus-visible:ring-white/60 focus-visible:ring-offset-transparent'
|
||||
: 'border border-white/30 bg-transparent text-[#F7F4EC] hover:bg-white/10 active:bg-white/10 focus-visible:ring-white/60 focus-visible:ring-offset-transparent'
|
||||
if (action.href) {
|
||||
return (
|
||||
<Button asChild className={styles}>
|
||||
<Link href={action.href}>{action.label}</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button className={styles} onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// Channel badges for the Underlag card, Wispr-style: translucent circles over
|
||||
// the image, one deliberately cropped by the card edge. Brand marks inline so
|
||||
// the card stays self-contained (Gmail's M, WhatsApp's glyph).
|
||||
const BADGE_STROKE = '#F4F1E8'
|
||||
|
||||
function badgeSvg(paths: string) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={BADGE_STROKE}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
dangerouslySetInnerHTML={{ __html: paths }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const FLOAT_BADGES: Array<{ style: CSSProperties; icon: ReactNode }> = [
|
||||
{
|
||||
style: { right: '34%', top: '8%', width: 38, height: 38 },
|
||||
icon: badgeSvg(
|
||||
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
||||
),
|
||||
},
|
||||
{
|
||||
style: { right: '6%', top: '14%', width: 46, height: 46 },
|
||||
icon: (
|
||||
<svg viewBox="0 0 48 48" aria-hidden="true">
|
||||
<path fill="#4caf50" d="M45 16.2l-5 2.75-5 4.75V40h7c1.657 0 3-1.343 3-3V16.2z" />
|
||||
<path fill="#1e88e5" d="M3 16.2l3.614 1.71L11 22.7V40H4c-1.657 0-3-1.343-3-3V16.2z" />
|
||||
<polygon fill="#e53935" points="35,11.2 24,19.45 13,11.2 12,17 13,23.7 24,31.95 35,23.7 36,17" />
|
||||
<path
|
||||
fill="#c62828"
|
||||
d="M3 12.298V16.2l8 6.5V11.2L7.4 8.504A1.61 1.61 0 0 0 6.41 8.174C4.527 8.174 3 9.7 3 11.583v.715z"
|
||||
/>
|
||||
<path
|
||||
fill="#fbc02d"
|
||||
d="M45 12.298V16.2l-8 6.5V11.2l3.6-2.696c.286-.214.633-.33.99-.33C43.473 8.174 45 9.7 45 11.583v.715z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
style: { right: '22%', top: '46%', width: 54, height: 54 },
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="#25D366"
|
||||
d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
style: { right: '5%', top: '64%', width: 42, height: 42 },
|
||||
icon: badgeSvg(
|
||||
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
||||
),
|
||||
},
|
||||
{
|
||||
style: { right: '26%', bottom: '-8%', width: 50, height: 50 },
|
||||
icon: badgeSvg(
|
||||
'<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function StartCard({
|
||||
card,
|
||||
layout,
|
||||
title,
|
||||
body,
|
||||
eyebrow,
|
||||
primary,
|
||||
secondary,
|
||||
floatIcons = false,
|
||||
dense = false,
|
||||
onDismiss,
|
||||
dismissLabel,
|
||||
className,
|
||||
}: StartCardProps) {
|
||||
const asset = START_CARDS[card]
|
||||
const g = asset.groundRgb
|
||||
|
||||
const scrim =
|
||||
layout === 'center'
|
||||
? `radial-gradient(ellipse 90% 130% at 50% 55%, rgba(${g}, 0.7) 0%, rgba(${g}, 0.36) 55%, rgba(${g}, 0.06) 100%)`
|
||||
: `linear-gradient(90deg, rgba(${g}, 0.92) 0%, rgba(${g}, 0.55) 45%, rgba(${g}, 0.08) 75%)`
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative h-[216px] overflow-hidden rounded-xl', className)}
|
||||
style={{ backgroundColor: asset.ground }}
|
||||
>
|
||||
{layout === 'side-right' ? (
|
||||
<div className="absolute inset-y-0 right-0 w-1/2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={asset.src}
|
||||
width={asset.w}
|
||||
height={asset.h}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
style={{ objectPosition: asset.objectPosition }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: `linear-gradient(90deg, ${asset.ground} 0%, rgba(${g}, 0) 42%)` }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={asset.src}
|
||||
width={asset.w}
|
||||
height={asset.h}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
style={{ objectPosition: asset.objectPosition }}
|
||||
/>
|
||||
<div className="absolute inset-0" style={{ background: scrim }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{floatIcons &&
|
||||
FLOAT_BADGES.map((badge, i) => (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
className="absolute z-[2] flex items-center justify-center rounded-full border border-white/15 backdrop-blur-[3px] [&>svg]:h-[46%] [&>svg]:w-[46%]"
|
||||
style={{ ...badge.style, backgroundColor: 'rgba(30, 36, 52, 0.55)' }}
|
||||
>
|
||||
{badge.icon}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label={dismissLabel}
|
||||
className="absolute right-4 top-3 z-[3] p-1 text-[#EBE5D3]/50 transition-colors duration-150 hover:text-[#EBE5D3]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-[2] flex h-full flex-col justify-center',
|
||||
dense ? 'px-6 py-4' : 'px-7 py-5',
|
||||
layout === 'center'
|
||||
? 'mx-auto max-w-[70%] items-center text-center'
|
||||
: floatIcons
|
||||
? 'max-w-[66%] items-start'
|
||||
: 'max-w-[58%] items-start',
|
||||
)}
|
||||
>
|
||||
{eyebrow && (
|
||||
<div
|
||||
className="mb-2 text-[10.5px] font-medium uppercase tracking-[0.12em]"
|
||||
style={{ color: EYEBROW_COLOR }}
|
||||
>
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
<h2
|
||||
className={cn('font-display text-balance', dense ? 'text-[19px] leading-snug' : 'text-2xl leading-tight')}
|
||||
style={{ color: TITLE_COLOR }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mb-4 mt-2 text-[13px] leading-relaxed" style={{ color: BODY_COLOR }}>
|
||||
{body}
|
||||
</p>
|
||||
<div className={cn('flex flex-wrap gap-2', layout === 'center' && 'justify-center')}>
|
||||
<ActionButton action={primary} kind="primary" />
|
||||
{secondary && <ActionButton action={secondary} kind="secondary" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Strata renders for the start cards (public/startkort/*.webp). Generated
|
||||
// deterministically by strata-engine in the CRM workspace (studio/strata):
|
||||
//
|
||||
// node stratify.mjs <source> linen-clear --bg <ground> [flags] --name <out>
|
||||
//
|
||||
// Per-file source + flags, so any card can be regenerated or re-grounded:
|
||||
// venice.webp imports/venice-canal-painting/04-commons-giovanni-antonio-canal...jpg --bg #3A2118
|
||||
// sthlm.webp imports/stockholm-gamla-stan-riddarholmen-waterfront-skyline/05-web...jpg --bg #16303B
|
||||
// geese.webp imports/byt-hero/02-commons-canadiangeeseflyinginvformation-jpg.jpg --bg #2E3C58
|
||||
// stopwatch.webp imports/konsult-hero/04-flickr-stopwatch.jpg (night-clear) --bg #322416
|
||||
// ledger.webp imports/ledger/01-commons-pittsboro-merchant-business-ledger-dpla-.jpg --bg #1F2B20 --tone 0.35
|
||||
// abacus.webp imports/abacus/04-commons-abacus-of-state-department-store-jpg.jpg (right unit crop) --bg #1B2136 --tone 0.5
|
||||
//
|
||||
// The ground color is baked into the render (the gaps between bars carry it),
|
||||
// so the card background and scrims below must stay in the same hue: change
|
||||
// them together or the image edge shows a seam. Intrinsic dimensions declared
|
||||
// up front so the <img> gets width/height and avoids layout shift.
|
||||
|
||||
export const START_CARDS = {
|
||||
venice: {
|
||||
src: '/startkort/venice.webp',
|
||||
w: 2400,
|
||||
h: 1466,
|
||||
ground: '#3A2118',
|
||||
groundRgb: '58, 33, 24',
|
||||
objectPosition: 'center 42%',
|
||||
},
|
||||
sthlm: {
|
||||
src: '/startkort/sthlm.webp',
|
||||
w: 2400,
|
||||
h: 1500,
|
||||
ground: '#16303B',
|
||||
groundRgb: '22, 48, 59',
|
||||
objectPosition: 'center 32%',
|
||||
},
|
||||
geese: {
|
||||
src: '/startkort/geese.webp',
|
||||
w: 2400,
|
||||
h: 1600,
|
||||
ground: '#2E3C58',
|
||||
groundRgb: '46, 60, 88',
|
||||
objectPosition: '0 26%',
|
||||
},
|
||||
stopwatch: {
|
||||
src: '/startkort/stopwatch.webp',
|
||||
w: 2400,
|
||||
h: 1800,
|
||||
ground: '#322416',
|
||||
groundRgb: '50, 36, 22',
|
||||
objectPosition: 'center 30%',
|
||||
},
|
||||
ledger: {
|
||||
src: '/startkort/ledger.webp',
|
||||
w: 2400,
|
||||
h: 3602,
|
||||
ground: '#1F2B20',
|
||||
groundRgb: '31, 43, 32',
|
||||
objectPosition: 'center 30%',
|
||||
},
|
||||
abacus: {
|
||||
src: '/startkort/abacus.webp',
|
||||
w: 2400,
|
||||
h: 2176,
|
||||
ground: '#1B2136',
|
||||
groundRgb: '27, 33, 54',
|
||||
objectPosition: 'center 35%',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type StartCardName = keyof typeof START_CARDS
|
||||
@@ -39,18 +39,16 @@ import {
|
||||
ExternalLink,
|
||||
FileQuestion,
|
||||
Search,
|
||||
Circle,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
MessageCircle,
|
||||
Maximize2,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { cn, formatCurrency, formatDate, formatDateLong } from '@/lib/utils'
|
||||
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { GoogleMark, MicrosoftMark } from '@/components/ui/provider-marks'
|
||||
import { StartCard } from '@/components/dashboard/StartCard'
|
||||
import EditKonteringDialog from '@/components/extensions/general/EditKonteringDialog'
|
||||
import InvoiceInboxSkeleton from '@/components/extensions/general/InvoiceInboxSkeleton'
|
||||
import { WhatsAppMark } from '@/components/extensions/general/WhatsAppMark'
|
||||
@@ -313,6 +311,7 @@ const WorkspaceSkeleton = InvoiceInboxSkeleton
|
||||
export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('inbox_workspace')
|
||||
const tStart = useTranslations('start_cards')
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
// Its own input: sharing the header's would upload without the purchase.
|
||||
const purchaseFileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
@@ -1572,16 +1571,20 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
// So: compact card on mobile only, quiet empty state on desktop.
|
||||
showOnboarding ? (
|
||||
<>
|
||||
<div className="xl:hidden">
|
||||
<OnboardingCard
|
||||
hasInboxAddress={hasInboxAddress}
|
||||
hasAnyItem={hasAnyItem}
|
||||
hasResolvedItem={hasResolvedItem}
|
||||
onActivateInbox={handleRotateAddress}
|
||||
onUploadClick={() => fileInputRef.current?.click()}
|
||||
<div className="xl:hidden p-3">
|
||||
<StartCard
|
||||
card="geese"
|
||||
layout="bleed-left"
|
||||
dense
|
||||
title={tStart('inbox_title')}
|
||||
body={tStart('inbox_body')}
|
||||
primary={{ label: tStart('inbox_primary'), href: '/settings/mail' }}
|
||||
secondary={{
|
||||
label: tStart('inbox_secondary'),
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
}}
|
||||
onDismiss={handleDismissOnboarding}
|
||||
isActivating={isRotating}
|
||||
compact
|
||||
dismissLabel={tStart('inbox_dismiss')}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden xl:block p-6 text-center text-sm text-muted-foreground">
|
||||
@@ -1721,16 +1724,27 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
onRetry={() => { void loadDocument(selected.id, selected.document_id) }}
|
||||
/>
|
||||
) : showOnboarding ? (
|
||||
<div className="h-full flex items-center justify-center px-4 py-6">
|
||||
<OnboardingCard
|
||||
hasInboxAddress={hasInboxAddress}
|
||||
hasAnyItem={hasAnyItem}
|
||||
hasResolvedItem={hasResolvedItem}
|
||||
onActivateInbox={handleRotateAddress}
|
||||
onUploadClick={() => fileInputRef.current?.click()}
|
||||
onDismiss={handleDismissOnboarding}
|
||||
isActivating={isRotating}
|
||||
/>
|
||||
<div className="h-full flex flex-col justify-center px-4 py-6">
|
||||
<div className="animate-fade-in">
|
||||
<StartCard
|
||||
card="geese"
|
||||
layout="bleed-left"
|
||||
dense
|
||||
floatIcons
|
||||
title={tStart('inbox_title')}
|
||||
body={tStart('inbox_body')}
|
||||
primary={{ label: tStart('inbox_primary'), href: '/settings/mail' }}
|
||||
secondary={{
|
||||
label: tStart('inbox_secondary'),
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
}}
|
||||
onDismiss={handleDismissOnboarding}
|
||||
dismissLabel={tStart('inbox_dismiss')}
|
||||
/>
|
||||
<p className="mt-4 text-center text-xs text-muted-foreground">
|
||||
{t('drop_anywhere_hint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyPreview
|
||||
@@ -2206,186 +2220,6 @@ export function DocumentPreview({
|
||||
|
||||
// ── Empty preview state ──────────────────────────────────────
|
||||
|
||||
// ── Onboarding card ──────────────────────────────────────────
|
||||
|
||||
interface OnboardingCardProps {
|
||||
hasInboxAddress: boolean
|
||||
hasAnyItem: boolean
|
||||
hasResolvedItem: boolean
|
||||
onActivateInbox: () => void
|
||||
onUploadClick: () => void
|
||||
onDismiss: () => void
|
||||
isActivating: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function OnboardingCard({
|
||||
hasInboxAddress,
|
||||
hasAnyItem,
|
||||
hasResolvedItem,
|
||||
onActivateInbox,
|
||||
onUploadClick,
|
||||
onDismiss,
|
||||
isActivating,
|
||||
compact = false,
|
||||
}: OnboardingCardProps) {
|
||||
// The card described the page as it was before the underlag rebuild: three
|
||||
// steps ending at "matcha eller bokför", with no mention that the page now
|
||||
// searches the mailboxes itself, lists the purchases that are missing a
|
||||
// receipt, or proposes the kontering. It also carried a Beta badge it had
|
||||
// outgrown.
|
||||
const steps = [
|
||||
{
|
||||
done: hasInboxAddress,
|
||||
title: 'Aktivera din inkorgsadress',
|
||||
hint: 'En egen adress som leverantörer kan fakturera direkt, och som du kan vidarebefordra kvitton till.',
|
||||
},
|
||||
{
|
||||
done: hasAnyItem,
|
||||
title: 'Koppla en brevlåda, maila in, eller ladda upp',
|
||||
hint: 'Med en kopplad brevlåda letar Kvittojakten själv upp kvitton till köp som saknar underlag. Utan den fyller du på för hand.',
|
||||
},
|
||||
{
|
||||
done: hasResolvedItem,
|
||||
title: 'Godkänn konteringen',
|
||||
hint: 'Matchade underlag får ett förslag på hur de bokförs, utifrån hur du bokfört samma leverantör förut. Du granskar och bokför.',
|
||||
},
|
||||
]
|
||||
// First incomplete step drives the active CTA. Falls back to -1 if all done
|
||||
// (the parent should have hidden the card by then, but guard anyway).
|
||||
const currentStep = steps.findIndex((s) => !s.done)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-lg border bg-card',
|
||||
compact ? 'mx-3 my-3 p-4 text-xs' : 'max-w-md mx-auto p-6'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dölj guide"
|
||||
className="absolute top-2 right-2 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<div className={cn('space-y-1', compact ? 'pr-6' : 'pr-8')}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2
|
||||
className={cn(
|
||||
'font-display tracking-tight',
|
||||
compact ? 'text-sm' : 'text-lg'
|
||||
)}
|
||||
>
|
||||
Så funkar Underlag
|
||||
</h2>
|
||||
</div>
|
||||
<p className={cn('text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
|
||||
Här samlas underlagen, och här syns köpen som saknar ett. Kvittojakten
|
||||
söker igenom kopplade brevlådor efter kvitton du inte fått in, och
|
||||
matchade underlag får ett förslag på kontering att godkänna. Att samla
|
||||
underlag är alltid gratis; AI-tolkning och kvittojakt ingår i
|
||||
abonnemanget.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol className={cn('space-y-2.5', compact ? 'mt-3' : 'mt-5')}>
|
||||
{steps.map((step, i) => {
|
||||
const isDone = step.done
|
||||
const isCurrent = !isDone && i === currentStep
|
||||
return (
|
||||
<li
|
||||
key={step.title}
|
||||
className={cn('flex items-start gap-2.5', compact && 'gap-2')}
|
||||
>
|
||||
<span className="shrink-0 mt-0.5">
|
||||
{isDone ? (
|
||||
<Check className={cn('text-success', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
|
||||
) : isCurrent ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground font-medium',
|
||||
compact ? 'h-3.5 w-3.5 text-[9px]' : 'h-4 w-4 text-[10px]'
|
||||
)}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
) : (
|
||||
<Circle className={cn('text-muted-foreground/40', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium',
|
||||
isDone ? 'text-muted-foreground line-through decoration-muted-foreground/40' : 'text-foreground',
|
||||
compact ? 'text-xs' : 'text-sm'
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
{!isDone && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground mt-0.5',
|
||||
compact ? 'text-[11px]' : 'text-xs'
|
||||
)}
|
||||
>
|
||||
{step.hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* CTA matches the current step. No CTA for step 3: it requires the user to pick a row. */}
|
||||
{currentStep === 0 && (
|
||||
<Button
|
||||
size={compact ? 'sm' : 'default'}
|
||||
className={cn('w-full mt-4', compact && 'h-8 text-xs')}
|
||||
onClick={onActivateInbox}
|
||||
disabled={isActivating}
|
||||
>
|
||||
{isActivating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Mail className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Aktivera inkorgsadress
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<div className={cn('mt-4 space-y-2', compact && 'mt-3')}>
|
||||
<Button
|
||||
size={compact ? 'sm' : 'default'}
|
||||
className={cn('w-full', compact && 'h-8 text-xs')}
|
||||
onClick={onUploadClick}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 mr-1.5" />
|
||||
Ladda upp en fil
|
||||
</Button>
|
||||
<p className={cn('text-center text-muted-foreground', compact ? 'text-[10px]' : 'text-[11px]')}>
|
||||
…eller maila underlagen till din inkorgsadress.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-4 text-center italic text-muted-foreground',
|
||||
compact ? 'text-[11px]' : 'text-xs'
|
||||
)}
|
||||
>
|
||||
Välj en post i listan för att matcha eller bokföra.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyPreview({
|
||||
onUploadClick,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { StartCard } from '@/components/dashboard/StartCard'
|
||||
import { Check, Upload, Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
@@ -13,22 +14,24 @@ interface InboxZeroStateProps {
|
||||
|
||||
export default function InboxZeroState({ hasTransactions, onCreateTransaction }: InboxZeroStateProps) {
|
||||
const t = useTranslations('tx_inbox_zero')
|
||||
const tStart = useTranslations('start_cards')
|
||||
|
||||
if (!hasTransactions) {
|
||||
// No transactions at all
|
||||
// No transactions at all: the start card leads with the bank connection
|
||||
// (the import page's own "Rekommenderat" path) and keeps the bank-file
|
||||
// import as the alternative. Manual creation stays reachable from the
|
||||
// header split button.
|
||||
return (
|
||||
<EmptyState icon={Upload} title={t('empty_title')} description={t('empty_description')}>
|
||||
<Button asChild>
|
||||
<Link href="/import">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t('import_btn')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onCreateTransaction}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('add_manual_btn')}
|
||||
</Button>
|
||||
</EmptyState>
|
||||
<div className="animate-fade-in">
|
||||
<StartCard
|
||||
card="sthlm"
|
||||
layout="bleed-left"
|
||||
title={tStart('transactions_title')}
|
||||
body={tStart('transactions_body')}
|
||||
primary={{ label: tStart('transactions_primary'), href: '/import?mode=psd2' }}
|
||||
secondary={{ label: tStart('transactions_secondary'), href: '/import?mode=bank' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3118,6 +3118,7 @@
|
||||
"purchase_searched_hint": "We searched the mailboxes. Drop the receipt here, or forward it to the inbox address.",
|
||||
"purchase_open_portal": "Open {vendor}",
|
||||
"purchase_drop": "Drop the file here, or click to choose",
|
||||
"drop_anywhere_hint": "… or drop files anywhere on the page",
|
||||
"purchase_uploading": "Uploading…",
|
||||
"purchase_kind": "Purchase without a document",
|
||||
"purchase_date": "Date",
|
||||
@@ -7102,6 +7103,41 @@
|
||||
"preset_reports_action": "Create invoice",
|
||||
"preset_reports_secondary": "Import transactions"
|
||||
},
|
||||
"start_cards": {
|
||||
"invoices_title": "Your first invoice takes two minutes.",
|
||||
"invoices_body": "Send it by email, and the payment is matched to the invoice when it arrives.",
|
||||
"invoices_primary": "Fetch customers and invoices",
|
||||
"invoices_secondary": "Create invoice",
|
||||
"transactions_title": "Connect your bank and the books stay a step ahead.",
|
||||
"transactions_body": "New transactions arrive every night and are matched to invoices and receipts; you approve with one click.",
|
||||
"transactions_primary": "Connect bank account",
|
||||
"transactions_secondary": "Import bank file",
|
||||
"inbox_title": "Drop the receipt here, and it's done.",
|
||||
"inbox_body": "Snap a photo, email it or drop the file here: the amount is read and matched to the right transaction.",
|
||||
"inbox_primary": "Connect mailboxes",
|
||||
"inbox_secondary": "Upload document",
|
||||
"inbox_dismiss": "Dismiss",
|
||||
"salary_eyebrow_start": "Get started",
|
||||
"salary_eyebrow_next": "Next step",
|
||||
"salary_title": "The 25th takes care of itself.",
|
||||
"salary_body_no_employees": "Add your first employee: tax, contributions and AGI are calculated and booked.",
|
||||
"salary_body_ready": "{count, plural, one {One employee is in place} other {# employees are in place}}: tax, contributions and AGI are calculated and booked.",
|
||||
"salary_primary_add": "Add employee",
|
||||
"salary_primary_run": "Start payroll run",
|
||||
"bookkeeping_title": "Your bookkeeping moves in within five minutes.",
|
||||
"bookkeeping_body": "Move in from your previous software: fiscal years, the chart of accounts and every voucher come along.",
|
||||
"bookkeeping_primary": "Fetch from another system",
|
||||
"bookkeeping_secondary": "Import SIE file",
|
||||
"bookkeeping_mini_mall_title": "From a template",
|
||||
"bookkeeping_mini_mall_body": "Rent, salary and other recurring events in one click.",
|
||||
"bookkeeping_mini_assistent_title": "With the assistant",
|
||||
"bookkeeping_mini_assistent_body": "Describe the event in words and get the posting suggested.",
|
||||
"bookkeeping_mini_tomt_title": "Blank voucher",
|
||||
"bookkeeping_mini_tomt_body": "Line by line, with the next voucher number ready.",
|
||||
"skattekonto_title": "Your tax account reconciles itself.",
|
||||
"skattekonto_body": "Connect with BankID and the balance and events are fetched continuously, ready to book.",
|
||||
"skattekonto_primary": "Connect Skatteverket"
|
||||
},
|
||||
"skattekonto": {
|
||||
"help_text": "The balance and events are fetched from Skatteverket and synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically; anything that cannot be matched is flagged in the list. Pay in via bankgiro 5050-1055 with your OCR number.",
|
||||
"copy": "copy",
|
||||
|
||||
@@ -3119,6 +3119,7 @@
|
||||
"purchase_open_portal": "Öppna {vendor}",
|
||||
"purchase_drop": "Släpp filen här, eller klicka för att välja",
|
||||
"purchase_uploading": "Laddar upp…",
|
||||
"drop_anywhere_hint": "… eller släpp filer var som helst på sidan",
|
||||
"purchase_kind": "Köp utan underlag",
|
||||
"purchase_date": "Datum",
|
||||
"purchase_amount": "Belopp",
|
||||
@@ -7102,6 +7103,41 @@
|
||||
"preset_reports_action": "Skapa faktura",
|
||||
"preset_reports_secondary": "Importera transaktioner"
|
||||
},
|
||||
"start_cards": {
|
||||
"invoices_title": "Din första faktura tar två minuter.",
|
||||
"invoices_body": "Skicka med ett mejl, och betalningen matchas mot fakturan när den kommer in.",
|
||||
"invoices_primary": "Hämta kunder och fakturor",
|
||||
"invoices_secondary": "Skapa faktura",
|
||||
"transactions_title": "Koppla banken, så ligger bokföringen steget före.",
|
||||
"transactions_body": "Nya transaktioner hämtas varje natt och matchas mot fakturor och kvitton; du godkänner med ett klick.",
|
||||
"transactions_primary": "Koppla bankkonto",
|
||||
"transactions_secondary": "Importera bankfil",
|
||||
"inbox_title": "Släpp kvittot här, sen är det klart.",
|
||||
"inbox_body": "Fota, mejla eller släpp filen här: beloppet läses av och matchas mot rätt transaktion.",
|
||||
"inbox_primary": "Koppla brevlådor",
|
||||
"inbox_secondary": "Ladda upp underlag",
|
||||
"inbox_dismiss": "Stäng",
|
||||
"salary_eyebrow_start": "Kom igång",
|
||||
"salary_eyebrow_next": "Nästa steg",
|
||||
"salary_title": "Den 25:e sköter sig själv.",
|
||||
"salary_body_no_employees": "Lägg till din första anställda: skatt, avgifter och AGI räknas fram och bokförs.",
|
||||
"salary_body_ready": "{count, plural, one {En anställd är på plats} other {# anställda är på plats}}: skatt, avgifter och AGI räknas fram och bokförs.",
|
||||
"salary_primary_add": "Lägg till anställd",
|
||||
"salary_primary_run": "Starta lönekörning",
|
||||
"bookkeeping_title": "Bokföringen flyttar in på fem minuter.",
|
||||
"bookkeeping_body": "Flytta in från ditt förra program: räkenskapsår, kontoplan och alla verifikat följer med.",
|
||||
"bookkeeping_primary": "Hämta från annat system",
|
||||
"bookkeeping_secondary": "Importera SIE-fil",
|
||||
"bookkeeping_mini_mall_title": "Från mall",
|
||||
"bookkeeping_mini_mall_body": "Hyra, lön och andra återkommande händelser på ett klick.",
|
||||
"bookkeeping_mini_assistent_title": "Med assistenten",
|
||||
"bookkeeping_mini_assistent_body": "Beskriv händelsen med ord och få konteringen föreslagen.",
|
||||
"bookkeeping_mini_tomt_title": "Tomt verifikat",
|
||||
"bookkeeping_mini_tomt_body": "Rad för rad, med nästa verifikatnummer redo.",
|
||||
"skattekonto_title": "Skattekontot stämmer av sig självt.",
|
||||
"skattekonto_body": "Anslut med BankID så hämtas saldo och händelser löpande, färdiga att bokföra.",
|
||||
"skattekonto_primary": "Anslut Skatteverket"
|
||||
},
|
||||
"skattekonto": {
|
||||
"help_text": "Saldot och händelserna hämtas från Skatteverket och synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt; det som inte kan matchas flaggas i listan. Betala in via bankgiro 5050-1055 med ditt OCR-nummer.",
|
||||
"copy": "kopiera",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Reference in New Issue
Block a user