feat(ui): rest-of-nav 1 — Viktiga datum, Skattekonto, Periodiseringar, Import to the concept language (#1140)
* feat(ui): concept scenes 17/24/32/33 for Viktiga datum, Skattekonto, Periodiseringar and Import (rest-of-nav 1) Viktiga datum: thread rows with type-icon circles behind a type seg (Alla/Skatt/Fakturering/Egna), Narmast countdown pane, Ny deadline lifted to the page header, both banners replaced by one AttnLine, mark-done via ConfirmDialog. DeadlineCard/DeadlineFilters die; DeadlineRow is the row. Skattekonto: card-less saldo hero with OCR + quiet copy, shortfall AttnLine computed from the next drain date with a betalningsuppgifter dialog (bankgiro 5050-1055 + OCR), one dry-table with Kommande/Forfallna/ Genomforda band rows, chips only on unbooked genomforda rows, quiet hover actions. Tabs and per-row badge noise are gone; the concept's Saldo column is dropped because SKV stores no per-row running balance. Periodiseringar: banner becomes an AttnLine with inline Bokfor forfallna (now confirm-first), house seg with Aktiva count, dry-table with muted normal states and animated RowFoldout for installments, Los upp nu as a quiet hover link through the shared ConfirmDialog. Import: tabs collapse into one two-column row list (Importera | Exportera) in the concept row language; SIE export moves into a small dialog and Molnsynkronisering folds the CloudBackupCard open in place. The /import?view=export#sie-export and /import#cloud-backup deep links keep working. Sandbox notice is an AttnLine. All four pages get stagger-enter, a help popover behind ? and sv+en keys for every new string. 9189 tests green, lint clean, guards pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(ui): align dashboard loading skeletons with the migrated page silhouettes Folds in the parallel WIP from this checkout at the founder's request: every loading.tsx under (dashboard) now mirrors its migrated page row-for-row (24px title block, pill actions, borderless table heads, single-line rows), and the shared (dashboard)/loading.tsx takes Hem's greeting + Att gora silhouette. Also lands the pending DECISIONS.md lines (onboarding swap plan note + rest-of-nav 1 deviations). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * polish(ui): authority logos, stat-tile skattekonto hero, quieter type on rest-of-nav 1 Viktiga datum: statutory deadlines wear the receiving authority's mark (Skatteverket for tax dates, Bolagsverket for arsredovisning/arsstamma) as a small badge on white; other deadlines keep the neutral type icons. Row dates go muted, titles drop font-medium, the Narmast countdown steps down to the house text-4xl display scale. Skattekonto: the 32px serif hero becomes two compact metric tiles in the KPIHeroCards idiom (saldo with the Skatteverket mark + OCR meta, nasta dragning with date and event count), matching how numbers read on the migrated pages. Periodiseringar: chevron column dropped; rows expand on click exactly like the verifikat list. Import: provider logo chips return on Hamta fran annat system (live- version parity) and Koppla bank carries the Enable Banking mark. Adds skatteverket(_color), bolagsverket, enable-banking plus claude/ anthropic marks (for future use) under public/logos/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * polish(ui): keep Importera and Exportera as separate tabs on the import page Founder feedback: the merged two-column landing goes back to the familiar split. The house seg switches between the Importera rows and the Exportera rows (SIE 4 dialog + Molnsynkronisering fold), ?view=export selects the export tab again and the hash deep links flip to it before opening their surface. Row language and logo chips stay. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): readable Enable Banking mark and full-width skattekonto The enable-banking.webp is the full stacked logo in white-on-transparent: invisible on the light chip and mush at 16px. The chip now uses a cropped 368px icon square (enable-banking-icon.png) with the marketing site's grayscale+brightness treatment in light mode and a white lift in dark. Skattekonto loses its max-w-3xl cap so the table stretches the content column exactly like Bokforing and Transaktioner; the saldo tiles take KPI-card width (lg:grid-cols-4). Import's tab columns stretch too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): address review-bot findings on rest-of-nav 1 CodeRabbit triage, all three confirmed real: the Bokfor forfallna attn action is hidden for read-only users instead of rendering a no-op link; a failed deadline edit rethrows so the form stays open with the user's input; authority marks are reserved for statutory (system-generated) deadlines: a manual tax-category deadline keeps the neutral icon. Compliance swarm's two high findings verified clean, no change needed: /api/bookkeeping/accruals/:id/dissolve and /api/deadlines/:id (+ /complete) all run through withRouteContext with company_id scoping and 404 on miss. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@@ -348,4 +348,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-23] Frame layout (UI-migration PR 1) is md:-gated and the panel owns desktop scroll: mobile keeps document flow + bottom nav (concept is desktop-first), and since Next's window scroll-to-top never fires for an inner scroll container, MainContainer resets panel scroll on route change. Button default size drops fixed h-10 for natural pill height (7px/16px padding per locked convention 3); sm/lg/icon keep their heights.
|
||||
[2026-07-23] Nav PR 2: ui_state persisted as one jsonb bag on user_preferences (founder-approved migration 20260723120000) rather than per-preference columns: cosmetic, never load-bearing, grows with PR 3/4 split-button modes. Sidebar width driven by an inline --nav-w CSS variable on #dash-shell instead of a [data-nav-collapsed] attribute rule: the Tailwind 4/Lightning CSS pipeline silently dropped the top-level attribute-selector rule from compiled output, and the inline variable is pipeline-proof. Register/Bokslut folds default closed (concept tidiness), forced open by an active child route. Discord-community row skipped: no invite URL exists in the repo; add when one lands.
|
||||
[2026-07-23] PR 3 primitives: new --attn token pair (38 50% 34% light / 38 45% 62% dark) for the one-sentence AttnLine instead of reusing --warning: the warning tone fails WCAG AA as 12.5px body text on the page background; chips/charts keep --warning. Stagger applied via className on the five target pages' DataList/TableBody containers (plan item 7), not baked into the DataList primitive: remaining pages adopt it in their own migration PRs where their skeletons are aligned at the same time.
|
||||
[2026-07-23] PR 4 template booking books directly through the normal journal-entries endpoint without the editor's account-activation retry flow: template accounts are standard BAS accounts that are active in practice, and an inactive-account error surfaces as a normal error toast pointing at the full editor. Voucher preview in the commit ConfirmDialog is indicative (voucher-sequences/next for today's period/default series); the atomic number assigned at commit is what the success toast shows.
|
||||
[2026-07-23] PR 4 template booking books directly through the normal journal-entries endpoint without the editor's account-activation retry flow: template accounts are standard BAS accounts that are active in practice, and an inactive-account error surfaces as a normal error toast pointing at the full editor. Voucher preview in the commit ConfirmDialog is indicative (voucher-sequences/next for today's period/default series); the atomic number assigned at commit is what the success toast shows.
|
||||
[2026-07-23] Onboarding swap plan (dev_docs/onboarding_migration_plan.md): journey UI replaces wizard as pure UI swap (server action/lookup/BankID contract untouched); JourneyOrb = own canvas component, NOT thinking-orbs (needs comet+check morphs, reverses earlier npm intent); /companies/new folded into same component mode='add'; rollout behind NEXT_PUBLIC_ONBOARDING_JOURNEY flag.
|
||||
[2026-07-24] BankID company picker in journey onboarding: searchable list (name + form/role + orgnr, "finns redan" section, filter with single-match Enter) is the standard at ANY engagement count; chips variant cut (founder call on concept c82c9358). Picking a company still runs zero Lens lookups: CompanyRoles prefill only, F-skatt/moms/rakenskapsar asked as questions per the plan's lookupRan rule.
|
||||
[2026-07-24] BankID onboarding lookup (SUPERSEDES the zero-Lens line above, founder call): picking a company in the BankID picker DOES run one Lens lookup and prefills facts (address, F-skatt, moms, fiscal year, SNI), same per-onboarding cost as typing an orgnr; the roster list itself stays free CompanyRoles data, never prefetched. lookupRan=false becomes the degradation path (TIC disabled/429/504 -> ask as questions). Implementation note: the journey must NOT carry over Step2's preverifiedOrgNumber lookup-suppression; the deep-linked orgnr triggers the same single lookup as manual entry.
|
||||
[2026-07-24] Rest-of-nav PR 1 (Viktiga datum/Skattekonto/Periodiseringar/Import) deviations from concept scenes 17/24/32/33: skattekonto Saldo column dropped (SKV API stores no per-row running balance; computing one client-side would be dishonest math); Periodiseringar ships without the concept's "Ny periodisering" header button (no standalone create flow exists, accruals are born from invoice rows, help popover explains); Import keeps FY selection inside the SIE-export dialog instead of a header FyPicker (FY only affects SIE export, a page-level picker would lie); concept's "Hela arkivet" export row omitted (no zip-archive endpoint exists).
|
||||
|
||||
@@ -3,21 +3,28 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
export default function BookkeepingLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Title (24px) + single "Nytt verifikat" split button (pill) */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-44" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-40" />
|
||||
<Skeleton className="h-10 w-44" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-9 w-36 rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
||||
{/* Borderless table (JournalEntryList): header row + single-line rows */}
|
||||
<div>
|
||||
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="ml-auto h-3 w-14" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-56" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,23 +6,15 @@
|
||||
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { AlertCircle, CalendarClock, ChevronDown, Loader2 } from 'lucide-react'
|
||||
import { CalendarClock } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, RowFoldout } from '@/components/ui/dry-table'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
@@ -40,13 +32,12 @@ type ScheduleWithInstallments = AccrualSchedule & {
|
||||
|
||||
type StatusFilter = 'active' | 'completed' | 'all'
|
||||
|
||||
const SCHEDULE_BADGE: Record<
|
||||
AccrualScheduleStatus,
|
||||
{ label: string; variant: 'secondary' | 'success' | 'outline' }
|
||||
> = {
|
||||
active: { label: 'Aktiv', variant: 'secondary' },
|
||||
completed: { label: 'Avslutad', variant: 'success' },
|
||||
cancelled: { label: 'Makulerad', variant: 'outline' },
|
||||
// Chips mark exceptions (design.md): Aktiv/Avslutad are normal states and
|
||||
// render as muted text; only Makulerad deviates.
|
||||
const SCHEDULE_STATUS_TEXT: Record<AccrualScheduleStatus, string> = {
|
||||
active: 'Aktiv',
|
||||
completed: 'Avslutad',
|
||||
cancelled: 'Makulerad',
|
||||
}
|
||||
|
||||
function monthLabel(periodMonth: string): string {
|
||||
@@ -70,11 +61,12 @@ export default function AccrualSchedulesPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('active')
|
||||
const [schedules, setSchedules] = useState<ScheduleWithInstallments[]>([])
|
||||
const [dueCount, setDueCount] = useState(0)
|
||||
const [activeCount, setActiveCount] = useState<number | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [isPosting, setIsPosting] = useState(false)
|
||||
const [postConfirmOpen, setPostConfirmOpen] = useState(false)
|
||||
const [dissolveTarget, setDissolveTarget] = useState<ScheduleWithInstallments | null>(null)
|
||||
const [isDissolving, setIsDissolving] = useState(false)
|
||||
|
||||
const fetchSchedules = useCallback(async (filter: StatusFilter) => {
|
||||
setIsLoading(true)
|
||||
@@ -84,6 +76,7 @@ export default function AccrualSchedulesPage() {
|
||||
if (!res.ok) throw new Error(getErrorMessage(json, { context: 'journal_entry' }))
|
||||
setSchedules(json.data ?? [])
|
||||
setDueCount(json.due_count ?? 0)
|
||||
if (filter === 'active') setActiveCount((json.data ?? []).length)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte ladda periodiseringar',
|
||||
@@ -140,7 +133,6 @@ export default function AccrualSchedulesPage() {
|
||||
|
||||
async function handleDissolve() {
|
||||
if (!dissolveTarget) return
|
||||
setIsDissolving(true)
|
||||
try {
|
||||
const res = await fetch(`/api/bookkeeping/accruals/${dissolveTarget.id}/dissolve`, {
|
||||
method: 'POST',
|
||||
@@ -159,8 +151,6 @@ export default function AccrualSchedulesPage() {
|
||||
description: getErrorMessage(error, { context: 'journal_entry' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsDissolving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,61 +165,88 @@ export default function AccrualSchedulesPage() {
|
||||
[schedules],
|
||||
)
|
||||
|
||||
const dissolveRemaining = dissolveTarget
|
||||
? roundOre(dissolveTarget.total_amount - sumPosted(dissolveTarget.installments))
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader title="Periodiseringar" />
|
||||
<PageHeader
|
||||
title="Periodiseringar"
|
||||
help={
|
||||
<HelpPopover>
|
||||
<p>
|
||||
Periodisera en fakturarad när du registrerar en leverantörsfaktura eller
|
||||
skapar en kundfaktura, så fördelas beloppet över månaderna här. Månadens
|
||||
andel bokförs automatiskt den sista dagen i varje månad.
|
||||
</p>
|
||||
</HelpPopover>
|
||||
}
|
||||
/>
|
||||
|
||||
{(dueCount > 0 || blockedInstallments > 0) && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-col gap-3 rounded-lg border border-border bg-secondary/40 p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
{dueCount > 0 ? (
|
||||
<AttnLine
|
||||
action={
|
||||
canWrite
|
||||
? {
|
||||
label: isPosting ? 'Bokför…' : 'Bokför förfallna',
|
||||
onClick: () => {
|
||||
if (!isPosting) setPostConfirmOpen(true)
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">
|
||||
{dueCount > 0
|
||||
? `${dueCount} ${dueCount === 1 ? 'månad väntar' : 'månader väntar'} på att bokföras`
|
||||
: 'Periodiseringar med fel'}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{blockedInstallments > 0
|
||||
? `${blockedInstallments} ${blockedInstallments === 1 ? 'månad kunde' : 'månader kunde'} inte bokföras automatiskt: öppna raden för felmeddelandet.`
|
||||
: 'Förfallna månader bokförs automatiskt varje natt, eller direkt här.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canWrite && dueCount > 0 && (
|
||||
<Button onClick={handlePostDue} disabled={isPosting} className="shrink-0">
|
||||
{isPosting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Bokför…
|
||||
</>
|
||||
) : (
|
||||
'Bokför förfallna'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{dueCount === 1
|
||||
? '1 månad väntar på att bokföras.'
|
||||
: `${dueCount} månader väntar på att bokföras.`}{' '}
|
||||
Förfallna månader bokförs annars automatiskt varje natt.
|
||||
</AttnLine>
|
||||
) : blockedInstallments > 0 ? (
|
||||
<AttnLine>
|
||||
{blockedInstallments === 1
|
||||
? '1 månad kunde inte bokföras automatiskt: öppna raden för felmeddelandet.'
|
||||
: `${blockedInstallments} månader kunde inte bokföras automatiskt: öppna raden för felmeddelandet.`}
|
||||
</AttnLine>
|
||||
) : null}
|
||||
|
||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">Aktiva</TabsTrigger>
|
||||
<TabsTrigger value="completed">Avslutade</TabsTrigger>
|
||||
<TabsTrigger value="all">Alla</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{/* Toolbar: status seg (concept scene 33) */}
|
||||
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
|
||||
{(
|
||||
[
|
||||
{ key: 'active', label: 'Aktiva', count: activeCount },
|
||||
{ key: 'completed', label: 'Avslutade', count: null },
|
||||
{ key: 'all', label: 'Alla', count: null },
|
||||
] as const
|
||||
).map(({ key, label, count }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={statusFilter === key}
|
||||
onClick={() => setStatusFilter(key)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
|
||||
statusFilter === key
|
||||
? 'border border-border bg-card font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{count !== null && count > 0 && (
|
||||
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : schedules.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={CalendarClock}
|
||||
@@ -237,22 +254,21 @@ export default function AccrualSchedulesPage() {
|
||||
description="Periodisera en fakturarad när du registrerar en leverantörsfaktura eller skapar en kundfaktura, så fördelas beloppet automatiskt över månaderna här."
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead>Konto</TableHead>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead className="text-right">Totalt</TableHead>
|
||||
<TableHead className="text-right">Kvar</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-28" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<>
|
||||
<div className="overflow-x-auto" role="region" aria-label="Periodiseringar">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={TH_CLASS}>Beskrivning</th>
|
||||
<th className={TH_CLASS}>Konto</th>
|
||||
<th className={TH_CLASS}>Period</th>
|
||||
<th className={cn(TH_CLASS, 'text-right')}>Totalt</th>
|
||||
<th className={cn(TH_CLASS, 'text-right')}>Kvar</th>
|
||||
<th className={TH_CLASS}>Status</th>
|
||||
<th className={cn(TH_CLASS, 'w-28')} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{schedules.map((schedule) => {
|
||||
const dissolved = sumPosted(schedule.installments)
|
||||
const remaining =
|
||||
@@ -260,7 +276,6 @@ export default function AccrualSchedulesPage() {
|
||||
? 0
|
||||
: roundOre(schedule.total_amount - dissolved)
|
||||
const isOpen = expanded.has(schedule.id)
|
||||
const badge = SCHEDULE_BADGE[schedule.status]
|
||||
const sourceHref = schedule.supplier_invoice_id
|
||||
? `/supplier-invoices/${schedule.supplier_invoice_id}`
|
||||
: schedule.invoice_id
|
||||
@@ -268,20 +283,11 @@ export default function AccrualSchedulesPage() {
|
||||
: null
|
||||
return (
|
||||
<Fragment key={schedule.id}>
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
<tr
|
||||
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
|
||||
onClick={() => toggleExpanded(schedule.id)}
|
||||
>
|
||||
<TableCell className="pr-0">
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform duration-150',
|
||||
isOpen && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[320px]">
|
||||
<td className={cn(TD_CLASS, 'max-w-[320px]')}>
|
||||
<span className="block truncate" title={schedule.description ?? ''}>
|
||||
{schedule.description || '-'}
|
||||
</span>
|
||||
@@ -294,118 +300,148 @@ export default function AccrualSchedulesPage() {
|
||||
{schedule.supplier_invoice_id ? 'Leverantörsfaktura' : 'Kundfaktura'}
|
||||
</Link>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums text-muted-foreground">
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
|
||||
{schedule.balance_account} → {schedule.target_account}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
|
||||
{formatDate(schedule.period_start)} till {formatDate(schedule.period_end)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
|
||||
{formatCurrency(schedule.total_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
|
||||
{formatCurrency(remaining)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={badge.variant}>{badge.label}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
|
||||
{schedule.status === 'cancelled' ? (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{SCHEDULE_STATUS_TEXT.cancelled}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{SCHEDULE_STATUS_TEXT[schedule.status]}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
|
||||
{canWrite && schedule.status === 'active' && remaining > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDissolveTarget(schedule)
|
||||
}}
|
||||
className={cn(
|
||||
QUIET_LINK_CLASS,
|
||||
'opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
Lös upp nu
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={8} className="bg-muted/30 p-0">
|
||||
<div className="px-6 py-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<th className="pb-2">Månad</th>
|
||||
<th className="pb-2 text-right">Belopp</th>
|
||||
<th className="pb-2 pl-6">Status</th>
|
||||
<th className="pb-2 pl-6">Verifikat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{schedule.installments.map((installment) => (
|
||||
<tr key={installment.id} className="border-t border-border/60">
|
||||
<td className="py-1.5 tabular-nums">
|
||||
{monthLabel(installment.period_month)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{formatCurrency(installment.amount)}
|
||||
</td>
|
||||
<td className="py-1.5 pl-6">
|
||||
{installment.status === 'posted' ? (
|
||||
<Badge variant="success">Bokförd</Badge>
|
||||
) : installment.status === 'cancelled' ? (
|
||||
<Badge variant="outline">Makulerad</Badge>
|
||||
) : installment.last_error ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Badge variant="destructive">Fel</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{installment.last_error}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<Badge variant="outline">Väntar</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 pl-6">
|
||||
{installment.journal_entry_id ? (
|
||||
<Link
|
||||
href={`/bookkeeping/${installment.journal_entry_id}`}
|
||||
className="text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
Öppna verifikat
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
<tr className="hover:bg-transparent">
|
||||
<td colSpan={7} className="border-b border-border bg-muted/30 p-0">
|
||||
<RowFoldout>
|
||||
<div className="px-6 py-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<th className="pb-2">Månad</th>
|
||||
<th className="pb-2 text-right">Belopp</th>
|
||||
<th className="pb-2 pl-6">Status</th>
|
||||
<th className="pb-2 pl-6">Verifikat</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</thead>
|
||||
<tbody>
|
||||
{schedule.installments.map((installment) => (
|
||||
<tr key={installment.id} className="border-t border-border/60">
|
||||
<td className="py-1.5 tabular-nums">
|
||||
{monthLabel(installment.period_month)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{formatCurrency(installment.amount)}
|
||||
</td>
|
||||
<td className="py-1.5 pl-6">
|
||||
{installment.status === 'posted' ? (
|
||||
<span className="text-xs text-muted-foreground">Bokförd</span>
|
||||
) : installment.status === 'cancelled' ? (
|
||||
<Badge variant="outline" className="font-normal">Makulerad</Badge>
|
||||
) : installment.last_error ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Badge variant="destructive" className="font-normal">Fel</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{installment.last_error}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Väntar</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 pl-6">
|
||||
{installment.journal_entry_id ? (
|
||||
<Link
|
||||
href={`/bookkeeping/${installment.journal_entry_id}`}
|
||||
className="text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
Öppna verifikat
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</RowFoldout>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="px-1 text-xs leading-5 text-muted-foreground">
|
||||
Månadens andel bokförs automatiskt den sista dagen i varje månad. Öppna en rad
|
||||
för att se varje månads verifikat.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{dissolveTarget && (
|
||||
<ConfirmationDialog
|
||||
open={!!dissolveTarget}
|
||||
onOpenChange={(open) => !open && setDissolveTarget(null)}
|
||||
onConfirm={handleDissolve}
|
||||
isSubmitting={isDissolving}
|
||||
title="Lös upp periodiseringen nu?"
|
||||
warningText={`Återstående ${formatCurrency(
|
||||
Math.round(
|
||||
(dissolveTarget.total_amount - sumPosted(dissolveTarget.installments)) * 100,
|
||||
) / 100,
|
||||
)} bokförs i ett verifikat daterat idag, och periodiseringen avslutas.`}
|
||||
confirmLabel="Lös upp nu"
|
||||
>
|
||||
<ConfirmDialog
|
||||
open={postConfirmOpen}
|
||||
onOpenChange={setPostConfirmOpen}
|
||||
title="Bokför förfallna månader?"
|
||||
description={
|
||||
dueCount === 1
|
||||
? 'Ett verifikat bokförs för den väntande månaden.'
|
||||
: `${dueCount} verifikat bokförs, ett per väntande månad.`
|
||||
}
|
||||
confirmLabel="Bokför"
|
||||
onConfirm={handlePostDue}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={dissolveTarget !== null}
|
||||
onOpenChange={(open) => !open && setDissolveTarget(null)}
|
||||
title="Lös upp periodiseringen nu?"
|
||||
description={
|
||||
dissolveTarget
|
||||
? `Återstående ${formatCurrency(dissolveRemaining)} bokförs i ett verifikat daterat idag, och periodiseringen avslutas.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Lös upp nu"
|
||||
onConfirm={handleDissolve}
|
||||
>
|
||||
{dissolveTarget && (
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium">{dissolveTarget.description || 'Periodisering'}</p>
|
||||
<p className="tabular-nums text-muted-foreground">
|
||||
@@ -413,8 +449,8 @@ export default function AccrualSchedulesPage() {
|
||||
{formatDate(dissolveTarget.period_start)} till {formatDate(dissolveTarget.period_end)}
|
||||
</p>
|
||||
</div>
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
export default function CustomersLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Title (24px) + export menu + "Ny kund" action (pills) */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="flex gap-2">
|
||||
@@ -10,18 +11,25 @@ export default function CustomersLoading() {
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="rounded-lg border border-border">
|
||||
|
||||
{/* Search toolbar */}
|
||||
<Skeleton className="h-9 w-full" />
|
||||
|
||||
{/* Borderless table: header row + single-line rows */}
|
||||
<div>
|
||||
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="ml-auto h-3 w-14" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between border-b border-border/60 px-4 py-3 last:border-b-0"
|
||||
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -8,11 +8,15 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { DeadlineList } from '@/components/deadlines/DeadlineList'
|
||||
import { DeadlineForm } from '@/components/deadlines/DeadlineForm'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AlertTriangle, ArrowRight, CalendarClock, Loader2 } from 'lucide-react'
|
||||
import { Lock, Plus } from 'lucide-react'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { Deadline } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -23,11 +27,14 @@ export default function DeadlinesPage() {
|
||||
const { company } = useCompany()
|
||||
const companyId = company?.id
|
||||
const t = useTranslations('deadlines')
|
||||
const { canWrite } = useCanWrite()
|
||||
const [deadlines, setDeadlines] = useState<Deadline[]>([])
|
||||
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
|
||||
const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 })
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingDeadline, setEditingDeadline] = useState<Deadline | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -240,6 +247,9 @@ export default function DeadlinesPage() {
|
||||
description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
// Rethrow (like create) so a failed edit keeps the form open with the
|
||||
// user's input instead of silently discarding it.
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,24 +275,60 @@ export default function DeadlinesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormSubmit = async (
|
||||
data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>,
|
||||
) => {
|
||||
if (editingDeadline) {
|
||||
await handleDeadlineEdit({ ...editingDeadline, ...data })
|
||||
} else {
|
||||
await handleDeadlineCreate(data)
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingDeadline(null)
|
||||
}
|
||||
|
||||
const openEdit = (deadline: Deadline) => {
|
||||
setEditingDeadline(deadline)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const pageHeader = (
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
help={
|
||||
<HelpPopover>
|
||||
<p>{t('help_text')}</p>
|
||||
<p className="mt-2">
|
||||
<Link href="/settings/tax" className="underline underline-offset-2">
|
||||
{t('generate_open_settings')}
|
||||
</Link>
|
||||
</p>
|
||||
</HelpPopover>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
onClick={() => setShowForm(true)}
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('read_only_tooltip') : undefined}
|
||||
>
|
||||
{canWrite ? <Plus className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
|
||||
{t('new_deadline')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader title={t('title')} />
|
||||
{pageHeader}
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="rounded-lg border p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 space-y-1">
|
||||
<Skeleton className="h-5 w-8 mx-auto" />
|
||||
<Skeleton className="h-3 w-6 mx-auto" />
|
||||
</div>
|
||||
<Skeleton className="w-px h-8" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<div key={i} className="flex items-center gap-3 py-3">
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -294,65 +340,57 @@ export default function DeadlinesPage() {
|
||||
// Statutory deadlines (moms, arbetsgivardeklaration, F-skatt) are generated
|
||||
// from the company's tax settings — none present usually means those
|
||||
// settings were never filled in, so point there instead of letting the page
|
||||
// read as an empty manual todo list.
|
||||
// read as an empty manual todo list. One attn line per page: this setup
|
||||
// nudge outranks the overdue-invoices note because it unlocks the page.
|
||||
const hasSystemDeadlines = deadlines.some((d) => d.source === 'system')
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader title={t('title')} />
|
||||
{pageHeader}
|
||||
|
||||
{!hasSystemDeadlines && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 rounded-lg border px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarClock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{t('no_system_deadlines_title')}</span>
|
||||
<span className="text-muted-foreground ml-1.5">
|
||||
{t('no_system_deadlines_description')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button size="sm" onClick={handleGenerateSystemDeadlines} disabled={isGenerating}>
|
||||
{isGenerating && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
{t('generate_action')}
|
||||
</Button>
|
||||
<Link
|
||||
href="/settings/tax"
|
||||
className="group inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('generate_open_settings')}
|
||||
<ArrowRight className="h-4 w-4 group-hover:translate-x-0.5 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overdue invoices alert */}
|
||||
{overdueInvoices.count > 0 && (
|
||||
<Link href="/invoices?status=unpaid" className="group block">
|
||||
<div className="flex items-center justify-between rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 transition-colors hover:bg-destructive/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{t('overdue_invoices', { count: overdueInvoices.count })}</span>
|
||||
<span className="text-muted-foreground ml-2 tabular-nums">
|
||||
{formatCurrency(overdueInvoices.total)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:translate-x-0.5 transition-transform" />
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{!hasSystemDeadlines ? (
|
||||
<AttnLine
|
||||
action={{
|
||||
label: isGenerating ? t('generating') : t('generate_action'),
|
||||
onClick: () => {
|
||||
if (!isGenerating) void handleGenerateSystemDeadlines()
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t('no_system_deadlines_title')} {t('no_system_deadlines_description')}
|
||||
</AttnLine>
|
||||
) : overdueInvoices.count > 0 ? (
|
||||
<AttnLine
|
||||
action={{ label: t('overdue_invoices_action'), href: '/invoices?status=unpaid' }}
|
||||
>
|
||||
{t('overdue_invoices', { count: overdueInvoices.count })} ·{' '}
|
||||
{formatCurrency(overdueInvoices.total)}.
|
||||
</AttnLine>
|
||||
) : null}
|
||||
|
||||
<DeadlineList
|
||||
deadlines={deadlines}
|
||||
customers={customers}
|
||||
onDeadlineCreate={handleDeadlineCreate}
|
||||
onDeadlineToggle={handleDeadlineToggle}
|
||||
onDeadlineEdit={handleDeadlineEdit}
|
||||
onDeadlineDelete={handleDeadlineDelete}
|
||||
onDeadlineEdit={openEdit}
|
||||
/>
|
||||
|
||||
<DeadlineForm
|
||||
open={showForm}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setShowForm(false)
|
||||
setEditingDeadline(null)
|
||||
}
|
||||
}}
|
||||
onSubmit={handleFormSubmit}
|
||||
onDelete={(deadline) => {
|
||||
if (deadline.id) {
|
||||
const full = deadlines.find((d) => d.id === deadline.id)
|
||||
if (full) void handleDeadlineDelete(full)
|
||||
}
|
||||
}}
|
||||
initialData={editingDeadline || undefined}
|
||||
customers={customers}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,16 +2,25 @@
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet, Download, AlertTriangle } from 'lucide-react'
|
||||
import { ArrowLeft, Landmark, Loader2, ChevronRight, Download, AlertTriangle } from 'lucide-react'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany, useCapability } from '@/contexts/CompanyContext'
|
||||
@@ -1944,6 +1953,8 @@ export default function ImportPage() {
|
||||
const { company } = useCompany()
|
||||
const [mode, setMode] = useState<ImportMode>(null)
|
||||
const [view, setView] = useState<'import' | 'export'>('import')
|
||||
const [sieDialogOpen, setSieDialogOpen] = useState(false)
|
||||
const [cloudOpen, setCloudOpen] = useState(false)
|
||||
const [userId, setUserId] = useState('')
|
||||
const [isSandbox, setIsSandbox] = useState(false)
|
||||
const [exportPeriodId, setExportPeriodId] = useState<string | null>(null)
|
||||
@@ -1994,20 +2005,24 @@ export default function ImportPage() {
|
||||
}
|
||||
}, [isSandbox, searchParams])
|
||||
|
||||
// Hash-based deep links (#cloud-backup, #sie-export) → switch to export tab and scroll
|
||||
// Hash-based deep links: both live on the export tab; #sie-export opens
|
||||
// the SIE dialog, #cloud-backup expands the cloud panel and scrolls to it.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const hash = window.location.hash
|
||||
if (hash === '#cloud-backup' || hash === '#sie-export') {
|
||||
if (hash === '#sie-export') {
|
||||
setView('export')
|
||||
setSieDialogOpen(true)
|
||||
} else if (hash === '#cloud-backup') {
|
||||
setView('export')
|
||||
setCloudOpen(true)
|
||||
setTimeout(() => {
|
||||
document.querySelector(hash)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
}, 50)
|
||||
}, 80)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleViewChange = (next: string) => {
|
||||
if (next !== 'import' && next !== 'export') return
|
||||
const handleViewChange = (next: 'import' | 'export') => {
|
||||
setView(next)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (next === 'export') params.set('view', 'export')
|
||||
@@ -2022,276 +2037,171 @@ export default function ImportPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title={view === 'export' ? t('export_title') : t('title')}
|
||||
description={view === 'export' ? t('export_subtitle') : undefined}
|
||||
title={t('title')}
|
||||
help={
|
||||
<HelpPopover>
|
||||
<p>{t('help_text')}</p>
|
||||
</HelpPopover>
|
||||
}
|
||||
/>
|
||||
|
||||
{mode === null && (
|
||||
<>
|
||||
{isSandbox && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/50 px-4 py-3">
|
||||
<Info className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('sandbox_disabled')}
|
||||
</p>
|
||||
{isSandbox && <AttnLine>{t('sandbox_disabled')}</AttnLine>}
|
||||
|
||||
{/* Importera / Exportera as separate tabs (house seg), like before */}
|
||||
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
|
||||
{(
|
||||
[
|
||||
{ key: 'import', label: t('tab_import') },
|
||||
{ key: 'export', label: t('tab_export') },
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === key}
|
||||
onClick={() => handleViewChange(key)}
|
||||
className={`rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
|
||||
view === key
|
||||
? 'border border-border bg-card font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view === 'import' ? (
|
||||
<div>
|
||||
<div className="stagger-enter">
|
||||
{hasBankingExtension && (
|
||||
<ImportRow
|
||||
title={t('psd2_title')}
|
||||
sub={t('psd2_description')}
|
||||
chip={
|
||||
hasBankSync ? (
|
||||
<Badge variant="success" className="font-normal">{t('psd2_recommended')}</Badge>
|
||||
) : (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium leading-none text-muted-foreground">
|
||||
{t('psd2_requires_subscription')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
chips={<LogoChip src="/logos/enable-banking-icon.png" name="Enable Banking" mono />}
|
||||
disabled={isSandbox}
|
||||
onClick={() => setMode('psd2')}
|
||||
/>
|
||||
)}
|
||||
{hasMigrationExtension && (
|
||||
<ImportRow
|
||||
title={t('migration_title')}
|
||||
sub={t('migration_description')}
|
||||
chips={
|
||||
<>
|
||||
<LogoChip src="/logos/fortnox.svg" name="Fortnox" />
|
||||
<LogoChip src="/logos/visma.jpeg" name="Visma" />
|
||||
<LogoChip src="/logos/bokio.png" name="Bokio" />
|
||||
<LogoChip src="/logos/bjornlunden.png" name="Björn Lundén" />
|
||||
<LogoChip src="/logos/Briox_logo.png" name="Briox" />
|
||||
</>
|
||||
}
|
||||
disabled={isSandbox}
|
||||
onClick={() => setMode('migration')}
|
||||
/>
|
||||
)}
|
||||
<ImportRow
|
||||
title={t('bankfile_title')}
|
||||
sub={t('bankfile_description')}
|
||||
onClick={() => setMode('bank')}
|
||||
/>
|
||||
<ImportRow
|
||||
title={t('csv_data_title')}
|
||||
sub={t('csv_data_description')}
|
||||
onClick={() => setMode('csv_data')}
|
||||
/>
|
||||
<ImportRow
|
||||
title={t('sie_title')}
|
||||
sub={t('sie_description')}
|
||||
onClick={() => setMode('sie')}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-4 px-1 text-xs leading-5 text-muted-foreground">{t('pgnote')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="stagger-enter">
|
||||
<ImportRow
|
||||
id="sie-export"
|
||||
title={t('export_sie_title')}
|
||||
sub={t('export_sie_description')}
|
||||
onClick={() => setSieDialogOpen(true)}
|
||||
/>
|
||||
{hasCloudBackup && (
|
||||
<ImportRow
|
||||
title={t('cloud_row_title')}
|
||||
sub={t('cloud_row_description')}
|
||||
expanded={cloudOpen}
|
||||
onClick={() => setCloudOpen((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{hasCloudBackup && cloudOpen && (
|
||||
<div id="cloud-backup" className="mt-6 scroll-mt-24">
|
||||
<CloudBackupCard />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs value={view} onValueChange={handleViewChange}>
|
||||
<TabsList className="grid w-full max-w-xs grid-cols-2">
|
||||
<TabsTrigger value="import">{t('tab_import')}</TabsTrigger>
|
||||
<TabsTrigger value="export">{t('tab_export')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="import" className="mt-6">
|
||||
<div className="space-y-2">
|
||||
{/* 1. Koppla bank */}
|
||||
{hasBankingExtension && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={isSandbox ? -1 : 0}
|
||||
aria-disabled={isSandbox}
|
||||
className={cn(
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
|
||||
isSandbox
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:border-foreground/15'
|
||||
)}
|
||||
onClick={() => { if (!isSandbox) setMode('psd2') }}
|
||||
onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('psd2') } }}
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
|
||||
<Landmark className="h-[18px] w-[18px] text-foreground/60" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{t('psd2_title')}</h3>
|
||||
{hasBankSync ? (
|
||||
<Badge variant="success">{t('psd2_recommended')}</Badge>
|
||||
) : (
|
||||
<span className="text-[11px] font-medium text-muted-foreground bg-secondary px-2 py-0.5 rounded-full leading-none">
|
||||
{t('psd2_requires_subscription')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed max-w-lg">
|
||||
{t('psd2_description')}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. Hämta från annat system */}
|
||||
{hasMigrationExtension === true && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={isSandbox ? -1 : 0}
|
||||
aria-disabled={isSandbox}
|
||||
className={cn(
|
||||
'group rounded-lg border bg-card p-6 transition-colors',
|
||||
isSandbox
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:border-foreground/15'
|
||||
)}
|
||||
onClick={() => { if (!isSandbox) setMode('migration') }}
|
||||
onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('migration') } }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
|
||||
<ArrowRightLeft className="h-[18px] w-[18px] text-foreground/60" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{t('migration_title')}</h3>
|
||||
<p className="text-sm mt-1 leading-relaxed max-w-lg underline decoration-foreground/20 underline-offset-2 text-muted-foreground">
|
||||
{t('migration_description')}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-4 ml-[52px]">
|
||||
{([
|
||||
{ name: 'Fortnox', logo: '/logos/fortnox.svg' },
|
||||
{ name: 'Visma', logo: '/logos/visma.jpeg' },
|
||||
{ name: 'Bokio', logo: '/logos/bokio.png' },
|
||||
{ name: 'Björn Lundén', logo: '/logos/bjornlunden.png' },
|
||||
{ name: 'Briox', logo: '/logos/Briox_logo.png' },
|
||||
] as const).map(provider => (
|
||||
<div key={provider.name} className="flex items-center gap-2 rounded border border-border bg-muted/30 px-2 py-1">
|
||||
<img src={provider.logo} alt={provider.name} className="h-4 w-4 shrink-0 rounded-sm object-contain" />
|
||||
<span className="text-[11px] font-medium text-muted-foreground">{provider.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Banktransaktioner: manual file imports (bank file, CSV/Excel,
|
||||
SIE) run entirely on uploaded data with no external service, so
|
||||
they stay available in the sandbox, unlike the API-backed options
|
||||
above (bank connection, provider migration) which need live
|
||||
credentials. */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
|
||||
'cursor-pointer hover:border-foreground/15'
|
||||
)}
|
||||
onClick={() => setMode('bank')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('bank') } }}
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
|
||||
<ArrowLeftRight className="h-[18px] w-[18px] text-foreground/60" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{t('bankfile_title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed max-w-lg">
|
||||
{t('bankfile_description')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{['CSV', 'OFX', 'SEB', 'Swedbank', 'Nordea'].map(fmt => (
|
||||
<span key={fmt} className="text-[11px] text-muted-foreground/80 bg-muted/80 px-1.5 py-0.5 rounded leading-none">
|
||||
{fmt}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* 4. CSV/Excel-data (ingående balanser, kunder, leverantörer) */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
|
||||
'cursor-pointer hover:border-foreground/15'
|
||||
)}
|
||||
onClick={() => setMode('csv_data')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('csv_data') } }}
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
|
||||
<FileSpreadsheet className="h-[18px] w-[18px] text-foreground/60" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{t('csv_data_title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed max-w-lg">
|
||||
{t('csv_data_description')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{[
|
||||
{ key: 'XLSX', label: 'XLSX' },
|
||||
{ key: 'CSV', label: 'CSV' },
|
||||
{ key: 'opening_balances', label: t('csv_chip_opening_balances') },
|
||||
{ key: 'customers', label: t('csv_chip_customers') },
|
||||
{ key: 'suppliers', label: t('csv_chip_suppliers') },
|
||||
{ key: 'articles', label: t('csv_chip_articles') },
|
||||
].map(chip => (
|
||||
<span key={chip.key} className="text-[11px] text-muted-foreground/80 bg-muted/80 px-1.5 py-0.5 rounded leading-none">
|
||||
{chip.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* 5. Bokföringsdata (SIE) */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
|
||||
'cursor-pointer hover:border-foreground/15'
|
||||
)}
|
||||
onClick={() => setMode('sie')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('sie') } }}
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
|
||||
<FileText className="h-[18px] w-[18px] text-foreground/60" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{t('sie_title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed max-w-lg">
|
||||
{t('sie_description')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{['SIE4', '.se'].map(fmt => (
|
||||
<span key={fmt} className="text-[11px] text-muted-foreground/80 bg-muted/80 px-1.5 py-0.5 rounded leading-none">
|
||||
{fmt}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="export" className="mt-6">
|
||||
{/* SIE export as a small centered dialog (concept overlay convention) */}
|
||||
<Dialog open={sieDialogOpen} onOpenChange={setSieDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-display text-lg tracking-tight">
|
||||
{t('export_sie_title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[13px] leading-relaxed">
|
||||
{t('export_sie_dialog_description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* SIE-export */}
|
||||
<div id="sie-export" className="scroll-mt-24 rounded-lg border border-border bg-card p-6">
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
|
||||
{/* Identity */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
|
||||
<FileSpreadsheet className="h-[18px] w-[18px] text-foreground/60" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{t('export_sie_title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{t('export_sie_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="space-y-4">
|
||||
<FiscalYearSelector
|
||||
value={exportPeriodId}
|
||||
onChange={setExportPeriodId}
|
||||
includeAllOption={false}
|
||||
hideFuturePeriods
|
||||
label={t('export_sie_period_label')}
|
||||
/>
|
||||
<label className="flex items-start gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded border-border"
|
||||
checked={exportExcludeClosing}
|
||||
onChange={(e) => setExportExcludeClosing(e.target.checked)}
|
||||
/>
|
||||
<span>{t('export_sie_exclude_closing_label')}</span>
|
||||
</label>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (exportPeriodId) {
|
||||
const params = new URLSearchParams({ period_id: exportPeriodId })
|
||||
if (exportExcludeClosing) params.set('exclude_closing', 'true')
|
||||
window.open(`/api/reports/sie-export?${params.toString()}`, '_blank')
|
||||
}
|
||||
}}
|
||||
disabled={!exportPeriodId}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('export_sie_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Molnsynkronisering (Google Drive) */}
|
||||
{hasCloudBackup && (
|
||||
<div id="cloud-backup" className="scroll-mt-24">
|
||||
<CloudBackupCard />
|
||||
</div>
|
||||
)}
|
||||
<FiscalYearSelector
|
||||
value={exportPeriodId}
|
||||
onChange={setExportPeriodId}
|
||||
includeAllOption={false}
|
||||
hideFuturePeriods
|
||||
label={t('export_sie_period_label')}
|
||||
/>
|
||||
<label className="flex cursor-pointer items-start gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded border-border"
|
||||
checked={exportExcludeClosing}
|
||||
onChange={(e) => setExportExcludeClosing(e.target.checked)}
|
||||
/>
|
||||
<span>{t('export_sie_exclude_closing_label')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (exportPeriodId) {
|
||||
const params = new URLSearchParams({ period_id: exportPeriodId })
|
||||
if (exportExcludeClosing) params.set('exclude_closing', 'true')
|
||||
window.open(`/api/reports/sie-export?${params.toString()}`, '_blank')
|
||||
}
|
||||
}}
|
||||
disabled={!exportPeriodId}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('export_sie_button')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2328,3 +2238,81 @@ export default function ImportPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Quiet action row (concept scene 32): borderless list row with title, muted
|
||||
// sub-line and a chevron; chips only for the recommended/gated exceptions.
|
||||
function ImportRow({
|
||||
title,
|
||||
sub,
|
||||
chip,
|
||||
chips,
|
||||
disabled = false,
|
||||
expanded,
|
||||
onClick,
|
||||
id,
|
||||
}: {
|
||||
title: string
|
||||
sub: string
|
||||
chip?: React.ReactNode
|
||||
/** Logo chips under the sub line (provider marks, as on the live page). */
|
||||
chips?: React.ReactNode
|
||||
disabled?: boolean
|
||||
/** For rows that fold a panel open below the grid (cloud backup). */
|
||||
expanded?: boolean
|
||||
onClick: () => void
|
||||
id?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-expanded={expanded}
|
||||
className={cn(
|
||||
'group flex w-full items-center justify-between gap-4 border-b border-border/60 px-1 py-3 text-left',
|
||||
'transition-colors duration-150 hover:bg-secondary/35',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||
{title}
|
||||
{chip}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{sub}</span>
|
||||
{chips && <span className="mt-2 flex flex-wrap gap-2">{chips}</span>}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-muted-foreground/40 transition-transform duration-150',
|
||||
'group-hover:translate-x-0.5 group-hover:text-muted-foreground',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Provider mark chip (same recipe as the pre-migration live page): tiny logo
|
||||
// on a quiet bordered chip, so integrations read as first-class brands.
|
||||
// `mono` is for light-on-transparent marks (Enable Banking): the marketing
|
||||
// site's grayscale+brightness treatment makes them read on a light ground,
|
||||
// with the inverse lift in dark mode.
|
||||
function LogoChip({ src, name, mono = false }: { src: string; name: string; mono?: boolean }) {
|
||||
return (
|
||||
<span className="flex items-center gap-2 rounded border border-border bg-muted/30 px-2 py-1">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 rounded-sm object-contain',
|
||||
mono &&
|
||||
'opacity-90 [filter:grayscale(100%)_brightness(0.18)] dark:[filter:grayscale(100%)_brightness(1.5)]',
|
||||
)}
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-muted-foreground">{name}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,25 +3,34 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
export default function InvoicesLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Title (24px) + primary action (single "Ny faktura" split button, pill) */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-36" />
|
||||
<Skeleton className="h-9 w-32 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
|
||||
{/* Toolbar: status picker chip + search + fiscal-year picker */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
<Skeleton className="h-9 flex-1" />
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Borderless table: header row over hairline-separated single-line rows */}
|
||||
<div>
|
||||
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="ml-auto h-3 w-14" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,47 +1,46 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/**
|
||||
* Shared loading fallback for the dashboard segment. It is the Suspense
|
||||
* fallback for Hem (app/(dashboard)/page.tsx) and for every child route that
|
||||
* has no loading.tsx of its own, so it deliberately mirrors Hem's silhouette:
|
||||
* a greeting hero (H1 + date line) followed by a single "Att göra"-style pane
|
||||
* of list rows (see DashboardContent / AttGoraSection).
|
||||
*
|
||||
* A greeting + hairline-separated rows reads honestly on Hem and stays neutral
|
||||
* on the plain list pages that fall back here (a page title + rows), which is
|
||||
* why it is not shaped like any one page's specific grid.
|
||||
*/
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Metrics row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border bg-card p-4 space-y-2">
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
<Skeleton className="h-7 w-28" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Greeting hero (title + date line) */}
|
||||
<section>
|
||||
<Skeleton className="h-7 w-52" />
|
||||
<Skeleton className="mt-2 h-3.5 w-64" />
|
||||
</section>
|
||||
|
||||
{/* Income / Expenses */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border bg-card p-6 space-y-3">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Alerts + deadlines */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
{/* Single pane: header over a hairline, then list rows */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between border-b border-border px-1 pb-2.5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
|
||||
<Skeleton className="ml-1 mt-5 mb-1 h-2.5 w-20" />
|
||||
<div>
|
||||
{['w-44', 'w-52', 'w-40', 'w-48', 'w-44'].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-3 border-b border-border px-1 py-3.5"
|
||||
>
|
||||
<Skeleton className="mt-px h-[15px] w-[15px] shrink-0 rounded" />
|
||||
<Skeleton className={`h-3.5 ${w}`} />
|
||||
<Skeleton className="ml-auto h-5 w-7 shrink-0 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,26 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
export default function PendingLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Title (24px) + conditional "Godkänn alla" action (pill) */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-9 w-36 rounded-full" />
|
||||
</div>
|
||||
<div className="rounded-lg border border-border">
|
||||
|
||||
{/* Toolbar: segmented tabs + source picker */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Skeleton className="h-9 w-44 rounded-full" />
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Per-operation cards with approve/reject actions */}
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-4 border-b border-border/60 px-4 py-4 last:border-b-0"
|
||||
className="flex items-center gap-4 rounded-lg border border-border p-4"
|
||||
>
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
<Skeleton className="h-7 w-7 shrink-0 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
export default function ReportsLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Title (24px) + fiscal-year picker chip */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-10 w-44" />
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-9 w-32 rounded-full" />
|
||||
</div>
|
||||
{/* Section heading (e.g. "Senaste rapporter") */}
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
|
||||
@@ -3,17 +3,29 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
export default function SalaryLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Title (24px) + single "Starta körning" action (pill) */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-9 w-40 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-28 rounded-lg" />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24 rounded-lg" />
|
||||
|
||||
{/* Borderless table of salary runs: header row + single-line rows */}
|
||||
<div>
|
||||
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="ml-auto h-3 w-14" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
@@ -29,9 +33,7 @@ import {
|
||||
AlertCircle,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileCheck,
|
||||
Landmark,
|
||||
Link2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
@@ -70,7 +72,9 @@ interface MatchCandidate {
|
||||
|
||||
export default function SkattekontoPage() {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('skattekonto')
|
||||
const hasSkvCapability = useCapability(CAPABILITY.skatteverket)
|
||||
const [showPayment, setShowPayment] = useState(false)
|
||||
const [saldo, setSaldo] = useState<SaldoEnvelope | null>(null)
|
||||
const [tx, setTx] = useState<TransaktionerEnvelope['data'] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -294,59 +298,85 @@ export default function SkattekontoPage() {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// Next charge (concept attn line): the earliest upcoming due date and the
|
||||
// sum of everything Skatteverket draws that day.
|
||||
const nextCharge = useMemo(() => {
|
||||
const upcoming = tx?.upcoming ?? []
|
||||
const dueOf = (r: StoredSkattekontoTransaction) =>
|
||||
r.forfallodatum ?? r.transaktionsdatum
|
||||
const due = upcoming.map(dueOf).sort()[0]
|
||||
if (!due) return null
|
||||
const rows = upcoming.filter((r) => dueOf(r) === due)
|
||||
const amount = rows.reduce((sum, r) => sum + Number(r.belopp_skatteverket), 0)
|
||||
return { due, count: rows.length, amount: Math.round(Math.abs(amount) * 100) / 100 }
|
||||
}, [tx])
|
||||
|
||||
const saldoNow = saldo?.data ? saldo.data.saldoSkatteverket : null
|
||||
const shortfall =
|
||||
nextCharge && saldoNow !== null && saldoNow < nextCharge.amount
|
||||
? Math.round((nextCharge.amount - saldoNow) * 100) / 100
|
||||
: null
|
||||
|
||||
const helpNode = (
|
||||
<HelpPopover>
|
||||
<p>{t('help_text')}</p>
|
||||
</HelpPopover>
|
||||
)
|
||||
|
||||
if (notConnected) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeading />
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeading />
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="Kunde inte hämta skattekontot"
|
||||
description="Något gick fel när saldo och transaktioner skulle hämtas. Försök igen om en stund."
|
||||
>
|
||||
<Button variant="outline" onClick={() => void reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Försök igen
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-8">
|
||||
<PageHeader title="Skattekonto" help={helpNode} />
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="Kunde inte hämta skattekontot"
|
||||
description="Något gick fel när saldo och transaktioner skulle hämtas. Försök igen om en stund."
|
||||
>
|
||||
<Button variant="outline" onClick={() => void reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Försök igen
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const data = saldo?.data ?? null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeading
|
||||
right={
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Skattekonto"
|
||||
help={helpNode}
|
||||
action={
|
||||
// The span carries the tooltip: `title` is suppressed on disabled elements.
|
||||
<span title={!hasSkvCapability ? 'Synk mot Skatteverket kräver ett abonnemang' : undefined}>
|
||||
<Button onClick={syncNow} disabled={syncing || !hasSkvCapability}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={syncNow}
|
||||
disabled={syncing || !hasSkvCapability}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||
{syncing ? 'Synkroniserar…' : 'Synkronisera nu'}
|
||||
</Button>
|
||||
@@ -354,72 +384,131 @@ export default function SkattekontoPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{reconnectMessage && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-destructive" />
|
||||
<p className="text-sm">{reconnectMessage}</p>
|
||||
{/* Saldo as compact stat tiles (house metric-card idiom, KPIHeroCards) */}
|
||||
<section className="space-y-4">
|
||||
{loading && !data ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Skeleton className="h-28 w-full rounded-lg" />
|
||||
<Skeleton className="h-28 w-full rounded-lg" />
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/tax">
|
||||
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
||||
Anslut igen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : !data ? (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
Inget saldo hämtat ännu: klicka på ”Synkronisera nu”.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src="/logos/skatteverket_color.svg"
|
||||
alt=""
|
||||
className="h-4 w-4 shrink-0 object-contain"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Saldo hos Skatteverket</p>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 font-display text-2xl tabular-nums tracking-tight',
|
||||
data.saldoSkatteverket < 0 && 'text-destructive',
|
||||
)}
|
||||
>
|
||||
{formatCurrency(data.saldoSkatteverket)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
OCR <span className="tabular-nums">{data.ocrNummer}</span>{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyOcr(data.ocrNummer)}
|
||||
className={QUIET_LINK_CLASS}
|
||||
aria-label="Kopiera OCR"
|
||||
>
|
||||
{t('copy')}
|
||||
</button>
|
||||
{saldo?.lastSyncedAt && (
|
||||
<>
|
||||
{' '}· synkad{' '}
|
||||
<span className="tabular-nums">{formatDateTime(saldo.lastSyncedAt)}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{data.rantaSkatteverket !== 0 && (
|
||||
<p className="mt-1 text-xs tabular-nums text-muted-foreground">
|
||||
Preliminär ränta: {formatCurrency(data.rantaSkatteverket)}
|
||||
</p>
|
||||
)}
|
||||
{data.saldoKronofogden !== 0 && (
|
||||
<p className="mt-1 text-xs font-medium tabular-nums text-destructive">
|
||||
Hos Kronofogden: {formatCurrency(data.saldoKronofogden)}
|
||||
{data.rantaKronofogden !== 0 &&
|
||||
` (ränta ${formatCurrency(data.rantaKronofogden)})`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BalanceHero saldo={saldo} loading={loading} onCopyOcr={copyOcr} />
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<p className="text-xs text-muted-foreground">Nästa dragning</p>
|
||||
{nextCharge ? (
|
||||
<>
|
||||
<p className="mt-2 font-display text-2xl tabular-nums tracking-tight">
|
||||
{formatCurrency(nextCharge.amount)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs tabular-nums text-muted-foreground">
|
||||
{formatDateLong(nextCharge.due)}
|
||||
{nextCharge.count > 1 && ` · ${nextCharge.count} händelser`}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Inga kommande dragningar.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transaktioner</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="booked">
|
||||
<TabsList>
|
||||
<TabsTrigger value="booked">
|
||||
Genomförda {tx?.booked ? `(${tx.booked.length})` : ''}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="overdue">
|
||||
Förfallna {tx?.overdue ? `(${tx.overdue.length})` : ''}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="upcoming">
|
||||
Kommande {tx?.upcoming ? `(${tx.upcoming.length})` : ''}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="booked" className="mt-4">
|
||||
<TransactionTable
|
||||
rows={tx?.booked ?? []}
|
||||
onBokfor={bokfor}
|
||||
onMatch={openMatch}
|
||||
bookingId={bookingId}
|
||||
emptyText="Inga genomförda transaktioner än."
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="overdue" className="mt-4">
|
||||
<TransactionTable
|
||||
rows={tx?.overdue ?? []}
|
||||
onBokfor={bokfor}
|
||||
onMatch={openMatch}
|
||||
bookingId={bookingId}
|
||||
emptyText="Inga förfallna transaktioner."
|
||||
showForfallodatum
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="upcoming" className="mt-4">
|
||||
<TransactionTable
|
||||
rows={tx?.upcoming ?? []}
|
||||
onBokfor={bokfor}
|
||||
onMatch={openMatch}
|
||||
bookingId={bookingId}
|
||||
emptyText="Inga kommande transaktioner."
|
||||
showForfallodatum
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{reconnectMessage ? (
|
||||
<AttnLine action={{ label: t('attn_reconnect_action'), href: '/settings/tax' }}>
|
||||
{reconnectMessage}
|
||||
</AttnLine>
|
||||
) : shortfall !== null && nextCharge ? (
|
||||
<AttnLine
|
||||
action={{ label: t('attn_show_payment'), onClick: () => setShowPayment(true) }}
|
||||
>
|
||||
{t('attn_shortfall', {
|
||||
date: formatDateLong(nextCharge.due),
|
||||
charge: formatCurrency(nextCharge.amount),
|
||||
missing: formatCurrency(shortfall),
|
||||
})}
|
||||
</AttnLine>
|
||||
) : null}
|
||||
|
||||
{data.informationstext.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Information från Skatteverket
|
||||
</p>
|
||||
{data.informationstext.map((info, i) => (
|
||||
<p key={i} className="text-xs leading-5 text-muted-foreground">
|
||||
{info}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* One dry table with band rows (concept): Kommande, Förfallna, Genomförda */}
|
||||
<SkattekontoTable
|
||||
tx={tx}
|
||||
onBokfor={bokfor}
|
||||
onMatch={openMatch}
|
||||
bookingId={bookingId}
|
||||
/>
|
||||
|
||||
<p className="px-1 text-xs leading-5 text-muted-foreground">
|
||||
{t('pgnote', { amount: formatCurrency(data?.saldoKronofogden ?? 0) })}
|
||||
</p>
|
||||
|
||||
<MatchDialog
|
||||
row={matchOpenFor}
|
||||
@@ -432,256 +521,224 @@ export default function SkattekontoPage() {
|
||||
}}
|
||||
onConfirm={confirmMatch}
|
||||
/>
|
||||
|
||||
<Dialog open={showPayment} onOpenChange={setShowPayment}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-display text-lg tracking-tight">
|
||||
{t('payment_title')}
|
||||
</DialogTitle>
|
||||
{nextCharge && (
|
||||
<DialogDescription className="text-[13px] leading-relaxed">
|
||||
{t('payment_description', {
|
||||
date: formatDateLong(nextCharge.due),
|
||||
charge: formatCurrency(nextCharge.amount),
|
||||
})}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<dt className="text-muted-foreground">{t('payment_bankgiro_label')}</dt>
|
||||
<dd className="font-medium tabular-nums">5050-1055</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<dt className="text-muted-foreground">OCR</dt>
|
||||
<dd className="flex items-center gap-2 font-medium tabular-nums">
|
||||
{data?.ocrNummer}
|
||||
{data && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyOcr(data.ocrNummer)}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Kopiera OCR"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{shortfall !== null && (
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<dt className="text-muted-foreground">{t('payment_shortfall_label')}</dt>
|
||||
<dd className="font-medium tabular-nums">{formatCurrency(shortfall)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PageHeading({ right }: { right?: React.ReactNode }) {
|
||||
return <PageHeader title="Skattekonto" action={right} />
|
||||
type TableSection = {
|
||||
key: 'upcoming' | 'overdue' | 'booked'
|
||||
label: string
|
||||
rows: SkattekontoTransactionWithSuggestion[]
|
||||
}
|
||||
|
||||
function BalanceHero({
|
||||
saldo,
|
||||
loading,
|
||||
onCopyOcr,
|
||||
}: {
|
||||
saldo: SaldoEnvelope | null
|
||||
loading: boolean
|
||||
onCopyOcr: (ocr: string) => void
|
||||
}) {
|
||||
if (loading && !saldo?.data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-sm text-muted-foreground">
|
||||
Hämtar saldo…
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!saldo?.data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
Inget saldo hämtat ännu: klicka på "Synkronisera nu".
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const { data } = saldo
|
||||
const skvNegative = data.saldoSkatteverket < 0
|
||||
const kfmNegative = data.saldoKronofogden < 0
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Skatteverket
|
||||
</p>
|
||||
<p
|
||||
className={`font-display text-2xl tabular-nums ${
|
||||
skvNegative ? 'text-destructive' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(data.saldoSkatteverket)}
|
||||
</p>
|
||||
{data.rantaSkatteverket !== 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
|
||||
Preliminär ränta: {formatCurrency(data.rantaSkatteverket)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Kronofogden
|
||||
</p>
|
||||
<p
|
||||
className={`font-display text-2xl tabular-nums ${
|
||||
kfmNegative ? 'text-destructive' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(data.saldoKronofogden)}
|
||||
</p>
|
||||
{data.rantaKronofogden !== 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
|
||||
Preliminär ränta: {formatCurrency(data.rantaKronofogden)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 border-t pt-4 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
OCR
|
||||
</p>
|
||||
<p className="flex items-center gap-2 font-medium tabular-nums">
|
||||
{data.ocrNummer}
|
||||
<button
|
||||
onClick={() => onCopyOcr(data.ocrNummer)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Kopiera OCR"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Nästa avstämning
|
||||
</p>
|
||||
<p className="font-medium tabular-nums">{data.nastaAvstamningsdatum}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Saldo per
|
||||
</p>
|
||||
<p className="font-medium tabular-nums">
|
||||
{formatDateLong(data.senastUppdaterad)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saldo.lastSyncedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Synkas automatiskt varje natt. Senast synkad{' '}
|
||||
<span className="tabular-nums">
|
||||
{formatDateTime(saldo.lastSyncedAt)}
|
||||
</span>
|
||||
. Skatteverket uppdaterar saldot periodvis: datumet ovan ändras
|
||||
inte varje gång du synkroniserar.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.informationstext.length > 0 && (
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide">
|
||||
Information från Skatteverket
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm">
|
||||
{data.informationstext.map((t, i) => (
|
||||
<li key={i}>{t}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function TransactionTable({
|
||||
rows,
|
||||
function SkattekontoTable({
|
||||
tx,
|
||||
onBokfor,
|
||||
onMatch,
|
||||
bookingId,
|
||||
emptyText,
|
||||
showForfallodatum = false,
|
||||
}: {
|
||||
rows: SkattekontoTransactionWithSuggestion[]
|
||||
tx: TransaktionerEnvelope['data'] | null
|
||||
onBokfor: (id: string) => void
|
||||
onMatch: (row: StoredSkattekontoTransaction) => void
|
||||
bookingId: string | null
|
||||
emptyText: string
|
||||
showForfallodatum?: boolean
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return <p className="py-8 text-center text-sm text-muted-foreground">{emptyText}</p>
|
||||
const t = useTranslations('skattekonto')
|
||||
|
||||
const allSections: TableSection[] = [
|
||||
{ key: 'upcoming', label: t('band_upcoming'), rows: tx?.upcoming ?? [] },
|
||||
{ key: 'overdue', label: t('band_overdue'), rows: tx?.overdue ?? [] },
|
||||
{ key: 'booked', label: t('band_booked'), rows: tx?.booked ?? [] },
|
||||
]
|
||||
const sections = allSections.filter((s) => s.rows.length > 0)
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t('no_transactions')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
{showForfallodatum && <TableHead>Förfallodatum</TableHead>}
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map(row => {
|
||||
const negative = Number(row.belopp_skatteverket) < 0
|
||||
const isBooked = !!row.journal_entry_id
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="tabular-nums">{formatDate(row.transaktionsdatum)}</TableCell>
|
||||
{showForfallodatum && (
|
||||
<TableCell className="tabular-nums">
|
||||
{row.forfallodatum ? formatDate(row.forfallodatum) : '-'}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
{row.transaktionstext}
|
||||
{!isBooked && row.match_suggestion && (
|
||||
<p className="mt-1 text-xs text-warning">
|
||||
Möjlig dublett av{' '}
|
||||
{row.match_suggestion.voucher_series && row.match_suggestion.voucher_number
|
||||
<div className="overflow-x-auto" role="region" aria-label="Skattekontohändelser">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={cn(TH_CLASS, 'w-[110px]')}>Datum</th>
|
||||
<th className={TH_CLASS}>Händelse</th>
|
||||
<th className={cn(TH_CLASS, 'text-right')}>Belopp</th>
|
||||
<th className={cn(TH_CLASS, 'w-[150px]')} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{sections.map((section) => (
|
||||
<Fragment key={section.key}>
|
||||
<tr className="bg-muted/30">
|
||||
<td
|
||||
colSpan={4}
|
||||
className="px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground"
|
||||
>
|
||||
{section.label}
|
||||
</td>
|
||||
</tr>
|
||||
{section.rows.map((row) => (
|
||||
<SkattekontoRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
section={section.key}
|
||||
onBokfor={onBokfor}
|
||||
onMatch={onMatch}
|
||||
bookingId={bookingId}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SkattekontoRow({
|
||||
row,
|
||||
section,
|
||||
onBokfor,
|
||||
onMatch,
|
||||
bookingId,
|
||||
}: {
|
||||
row: SkattekontoTransactionWithSuggestion
|
||||
section: TableSection['key']
|
||||
onBokfor: (id: string) => void
|
||||
onMatch: (row: StoredSkattekontoTransaction) => void
|
||||
bookingId: string | null
|
||||
}) {
|
||||
const t = useTranslations('skattekonto')
|
||||
const amount = Number(row.belopp_skatteverket)
|
||||
const isBooked = !!row.journal_entry_id
|
||||
const displayDate =
|
||||
section === 'upcoming' || section === 'overdue'
|
||||
? (row.forfallodatum ?? row.transaktionsdatum)
|
||||
: row.transaktionsdatum
|
||||
|
||||
return (
|
||||
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
|
||||
{formatDate(displayDate)}
|
||||
</td>
|
||||
<td className={TD_CLASS}>
|
||||
<span className="inline-flex flex-wrap items-center gap-2">
|
||||
{row.transaktionstext}
|
||||
{/* Chips mark exceptions: only a *genomförd* row that is still
|
||||
unbooked deviates; upcoming rows are unbooked by nature. */}
|
||||
{section === 'booked' && !isBooked && (
|
||||
row.match_suggestion ? (
|
||||
<Badge variant="warning" className="font-normal">
|
||||
{t('chip_possible_duplicate', {
|
||||
voucher:
|
||||
row.match_suggestion.voucher_series && row.match_suggestion.voucher_number
|
||||
? formatVoucher({
|
||||
voucher_series: row.match_suggestion.voucher_series,
|
||||
voucher_number: row.match_suggestion.voucher_number,
|
||||
})
|
||||
: 'utkast'}{' '}
|
||||
({formatDate(row.match_suggestion.entry_date)})
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right tabular-nums ${negative ? 'text-destructive' : ''}`}
|
||||
>
|
||||
{formatCurrency(Number(row.belopp_skatteverket))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isBooked ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<FileCheck className="h-3 w-3" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
) : row.match_suggestion ? (
|
||||
<Badge variant="warning">Möjlig dublett</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Ej bokförd</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isBooked ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/bookkeeping/${row.journal_entry_id}`}>
|
||||
Visa verifikat
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onMatch(row)}
|
||||
title="Koppla till befintligt verifikat"
|
||||
>
|
||||
<Link2 className="mr-1 h-3.5 w-3.5" />
|
||||
Matcha
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onBokfor(row.id)}
|
||||
disabled={bookingId === row.id}
|
||||
>
|
||||
{bookingId === row.id ? 'Bokför…' : 'Bokför'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
: t('chip_draft'),
|
||||
})}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{t('chip_not_booked')}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
TD_CLASS,
|
||||
'whitespace-nowrap text-right tabular-nums',
|
||||
amount > 0 && 'text-success',
|
||||
)}
|
||||
>
|
||||
{amount > 0 ? `+${formatCurrency(amount)}` : formatCurrency(amount)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
|
||||
{isBooked ? (
|
||||
<Link
|
||||
href={`/bookkeeping/${row.journal_entry_id}`}
|
||||
className={cn(
|
||||
QUIET_LINK_CLASS,
|
||||
'opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
{t('action_show_voucher')}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-3 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMatch(row)}
|
||||
className={QUIET_LINK_CLASS}
|
||||
title="Koppla till befintligt verifikat"
|
||||
>
|
||||
{t('action_match')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBokfor(row.id)}
|
||||
disabled={bookingId === row.id}
|
||||
className={QUIET_LINK_CLASS}
|
||||
>
|
||||
{bookingId === row.id ? t('action_booking') : t('action_book')}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,27 +3,34 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
export default function SupplierInvoicesLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Title (24px) + single "Registrera" action (pill) */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-9 w-36 rounded-full" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-72" />
|
||||
|
||||
{/* Toolbar: status picker chip + search + fiscal-year picker */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
<Skeleton className="h-9 flex-1" />
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
</div>
|
||||
<div className="rounded-lg border border-border">
|
||||
|
||||
{/* Borderless table: header row + single-line rows */}
|
||||
<div>
|
||||
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="ml-auto h-3 w-14" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between border-b border-border/60 px-4 py-3 last:border-b-0"
|
||||
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-44" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-44" />
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,23 +3,35 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
export default function TransactionsLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Status bar: title (24px) + single "Importera" split button (pill) */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-10 w-36" />
|
||||
<Skeleton className="h-8 w-44" />
|
||||
<Skeleton className="h-9 w-32 rounded-full" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
|
||||
{/* Toolbar: inbox/history segmented tabs + search + source picker */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Skeleton className="h-9 w-44 rounded-full" />
|
||||
<Skeleton className="h-9 flex-1" />
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="space-y-3">
|
||||
|
||||
{/* Borderless table: header row + single-line transaction rows */}
|
||||
<div>
|
||||
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="ml-auto h-3 w-14" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Deadline } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
isDeadlineOverdue,
|
||||
DEADLINE_TYPE_LABELS,
|
||||
parseDate,
|
||||
startOfDay,
|
||||
} from '@/lib/calendar/utils'
|
||||
import { Check, Pencil } from 'lucide-react'
|
||||
|
||||
interface DeadlineCardProps {
|
||||
deadline: Deadline
|
||||
onToggle: (deadline: Deadline) => void
|
||||
onEdit?: (deadline: Deadline) => void
|
||||
onDelete?: (deadline: Deadline) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function getRelativeDate(dateStr: string): { text: string; urgent: boolean } | null {
|
||||
const today = startOfDay(new Date())
|
||||
const target = parseDate(dateStr)
|
||||
const diffMs = target.getTime() - today.getTime()
|
||||
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays === 0) return { text: 'Idag', urgent: true }
|
||||
if (diffDays === 1) return { text: 'Imorgon', urgent: false }
|
||||
if (diffDays === -1) return { text: '1 dag sedan', urgent: true }
|
||||
if (diffDays < -1) return { text: `${Math.abs(diffDays)} dagar sedan`, urgent: true }
|
||||
if (diffDays <= 7) return { text: `Om ${diffDays} dagar`, urgent: false }
|
||||
if (diffDays <= 14) return { text: `Om ${diffDays} dagar`, urgent: false }
|
||||
return null
|
||||
}
|
||||
|
||||
const SWEDISH_MONTHS_SHORT = [
|
||||
'jan', 'feb', 'mar', 'apr', 'maj', 'jun',
|
||||
'jul', 'aug', 'sep', 'okt', 'nov', 'dec',
|
||||
]
|
||||
|
||||
export function DeadlineCard({
|
||||
deadline,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
compact = false,
|
||||
}: DeadlineCardProps) {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const confirmRef = useRef<HTMLDivElement>(null)
|
||||
const overdue = isDeadlineOverdue(deadline)
|
||||
const completed = deadline.is_completed
|
||||
const relative = getRelativeDate(deadline.due_date)
|
||||
const dueDate = parseDate(deadline.due_date)
|
||||
const dayNum = dueDate.getDate()
|
||||
const monthStr = SWEDISH_MONTHS_SHORT[dueDate.getMonth()]
|
||||
|
||||
// Dismiss confirmation when clicking outside
|
||||
useEffect(() => {
|
||||
if (!confirming) return
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (confirmRef.current && !confirmRef.current.contains(e.target as Node)) {
|
||||
setConfirming(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [confirming])
|
||||
|
||||
function handleToggleClick(e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
if (completed) {
|
||||
// Uncompleting doesn't need confirmation
|
||||
onToggle(deadline)
|
||||
return
|
||||
}
|
||||
setConfirming(true)
|
||||
}
|
||||
|
||||
function handleConfirm(e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
setConfirming(false)
|
||||
onToggle(deadline)
|
||||
}
|
||||
|
||||
function handleCancel(e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
setConfirming(false)
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (confirming) return
|
||||
if (onEdit) onEdit(deadline)
|
||||
}
|
||||
|
||||
// -- Compact variant (used in dashboard widgets) --
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 py-2.5 transition-colors',
|
||||
onEdit && !confirming && 'cursor-pointer hover:bg-accent/30 -mx-2 px-2 rounded-md',
|
||||
completed && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<span className="text-xs tabular-nums w-14 flex-shrink-0 text-muted-foreground">
|
||||
{dayNum} {monthStr}
|
||||
</span>
|
||||
|
||||
<p className={cn(
|
||||
'text-sm truncate flex-1 min-w-0',
|
||||
completed ? 'line-through text-muted-foreground' : 'text-foreground',
|
||||
)}>
|
||||
{deadline.title}
|
||||
</p>
|
||||
|
||||
{!completed && relative && (
|
||||
<span className={cn(
|
||||
'text-xs flex-shrink-0',
|
||||
relative.urgent ? 'text-destructive font-medium' : 'text-muted-foreground',
|
||||
)}>
|
||||
{relative.text}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{completed && (
|
||||
<Check className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// -- Full card --
|
||||
return (
|
||||
<div ref={confirmRef}>
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className={cn(
|
||||
'group rounded-lg border bg-card transition-all duration-150',
|
||||
onEdit && !confirming && 'cursor-pointer',
|
||||
confirming && 'ring-2 ring-ring ring-offset-1',
|
||||
completed
|
||||
? 'opacity-50 hover:opacity-70'
|
||||
: !confirming && 'hover:bg-accent/40 active:bg-accent/60',
|
||||
)}
|
||||
>
|
||||
{/* Main row */}
|
||||
<div className="flex items-center gap-4 py-3 px-4">
|
||||
{/* Date block */}
|
||||
<div className="flex-shrink-0 w-12 text-center">
|
||||
<span className={cn(
|
||||
'block text-lg font-display leading-none tabular-nums',
|
||||
completed && 'text-muted-foreground',
|
||||
)}>
|
||||
{dayNum}
|
||||
</span>
|
||||
<span className="block text-[11px] uppercase tracking-wider text-muted-foreground mt-0.5">
|
||||
{monthStr}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-8 bg-border flex-shrink-0" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className={cn(
|
||||
'text-sm font-medium truncate',
|
||||
completed && 'line-through text-muted-foreground',
|
||||
)}>
|
||||
{deadline.title}
|
||||
</p>
|
||||
{deadline.deadline_type !== 'other' && (
|
||||
<span className="text-[11px] text-muted-foreground/60 flex-shrink-0 hidden sm:inline">
|
||||
{DEADLINE_TYPE_LABELS[deadline.deadline_type]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{deadline.due_time && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
kl {deadline.due_time.slice(0, 5)}
|
||||
</span>
|
||||
)}
|
||||
{deadline.due_time && deadline.customer && (
|
||||
<span className="text-muted-foreground/30 text-xs">·</span>
|
||||
)}
|
||||
{deadline.customer && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{deadline.customer.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: relative date + action */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{!completed && relative && (
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums hidden sm:inline',
|
||||
relative.urgent ? 'text-destructive font-medium' : 'text-muted-foreground',
|
||||
)}>
|
||||
{relative.text}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{completed ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Klar
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleToggleClick}
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground hover:text-foreground transition-colors px-2.5 py-1 rounded-md border border-border hover:border-foreground/30 hover:bg-accent',
|
||||
confirming && 'invisible',
|
||||
)}
|
||||
>
|
||||
Markera klar
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Edit hint */}
|
||||
{onEdit && !confirming && (
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground/0 group-hover:text-muted-foreground/50 transition-colors flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline confirmation bar */}
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-200 ease-out',
|
||||
confirming ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="border-t px-4 py-3 flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Markera <span className="font-medium text-foreground">{deadline.title}</span> som klar?
|
||||
</p>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 mr-1.5" />
|
||||
Bekräfta
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { DeadlineType } from '@/types'
|
||||
import { DEADLINE_TYPE_LABELS } from '@/lib/calendar/utils'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface DeadlineFiltersProps {
|
||||
status: 'all' | 'pending' | 'completed'
|
||||
type: DeadlineType | 'all'
|
||||
onStatusChange: (status: 'all' | 'pending' | 'completed') => void
|
||||
onTypeChange: (type: DeadlineType | 'all') => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export function DeadlineFilters({
|
||||
status,
|
||||
type,
|
||||
onStatusChange,
|
||||
onTypeChange,
|
||||
onReset,
|
||||
}: DeadlineFiltersProps) {
|
||||
const hasFilters = status !== 'all' || type !== 'all'
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={status} onValueChange={(v) => onStatusChange(v as typeof status)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alla</SelectItem>
|
||||
<SelectItem value="pending">Ej klara</SelectItem>
|
||||
<SelectItem value="completed">Klara</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={type} onValueChange={(v) => onTypeChange(v as typeof type)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Typ" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alla typer</SelectItem>
|
||||
{Object.entries(DEADLINE_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{hasFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onReset}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Rensa
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Deadline } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isDeadlineOverdue, parseDate } from '@/lib/calendar/utils'
|
||||
import { Check, Pencil } from 'lucide-react'
|
||||
import { isDeadlineOverdue } from '@/lib/calendar/utils'
|
||||
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { deadlineDateLabel } from './DeadlineRow'
|
||||
import { Pencil } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* System tax deadlines that legally share the skattekonto date ("den 12:e"):
|
||||
* for a small monthly-moms employer, moms + AGI (+ debiterad preliminärskatt)
|
||||
* all fall due the same day. Rendering them as one grouped card instead of
|
||||
* all fall due the same day. Rendering them as one grouped row instead of
|
||||
* 2-3 identical-date rows keeps the list scannable.
|
||||
*/
|
||||
export const SKATTEKONTO_GROUP_TYPES = new Set([
|
||||
@@ -32,158 +32,81 @@ export function isSkattekontoDeadline(d: Deadline): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
const SWEDISH_MONTHS_SHORT = [
|
||||
'jan', 'feb', 'mar', 'apr', 'maj', 'jun',
|
||||
'jul', 'aug', 'sep', 'okt', 'nov', 'dec',
|
||||
]
|
||||
|
||||
interface DeadlineGroupCardProps {
|
||||
interface DeadlineGroupRowProps {
|
||||
/** Two or more skattekonto deadlines sharing the same due_date. */
|
||||
deadlines: Deadline[]
|
||||
onToggle: (deadline: Deadline) => void
|
||||
onEdit?: (deadline: Deadline) => void
|
||||
/** "Markera klar": the list confirms before toggling. */
|
||||
onRequestToggle: (deadline: Deadline) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One card for all skattekonto obligations on a shared due date, with the
|
||||
* date block rendered once and each obligation as a sub-row that keeps its
|
||||
* own "Markera klar" confirmation. Visual language mirrors DeadlineCard.
|
||||
* One thread row for all skattekonto obligations on a shared due date
|
||||
* (concept scene 17 row language): the date renders once, each obligation is
|
||||
* a sub-row with its own quiet "Markera klar".
|
||||
*/
|
||||
export function DeadlineGroupCard({ deadlines, onToggle, onEdit }: DeadlineGroupCardProps) {
|
||||
export function DeadlineGroupCard({ deadlines, onEdit, onRequestToggle }: DeadlineGroupRowProps) {
|
||||
const t = useTranslations('deadlines')
|
||||
const [confirmingId, setConfirmingId] = useState<string | null>(null)
|
||||
const confirmRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const first = deadlines[0]
|
||||
const dueDate = parseDate(first.due_date)
|
||||
const dayNum = dueDate.getDate()
|
||||
const monthStr = SWEDISH_MONTHS_SHORT[dueDate.getMonth()]
|
||||
const overdue = deadlines.some(isDeadlineOverdue)
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmingId) return
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (confirmRef.current && !confirmRef.current.contains(e.target as Node)) {
|
||||
setConfirmingId(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [confirmingId])
|
||||
|
||||
const confirming = deadlines.find((d) => d.id === confirmingId) ?? null
|
||||
|
||||
return (
|
||||
<div ref={confirmRef}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border bg-card transition-all duration-150',
|
||||
confirmingId && 'ring-2 ring-ring ring-offset-1',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4 py-3 px-4">
|
||||
{/* Shared date block */}
|
||||
<div className="flex-shrink-0 w-12 text-center pt-0.5">
|
||||
<span className="block text-lg font-display leading-none tabular-nums">
|
||||
{dayNum}
|
||||
</span>
|
||||
<span className="block text-[11px] uppercase tracking-wider text-muted-foreground mt-0.5">
|
||||
{monthStr}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 py-3 -mx-2 px-2">
|
||||
{/* Shared skattekonto date: always Skatteverket's mark (white ground,
|
||||
badge not chrome). */}
|
||||
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-white">
|
||||
<img src="/logos/skatteverket_color.svg" alt="Skatteverket" className="h-4 w-4 object-contain" />
|
||||
</span>
|
||||
|
||||
<div className="w-px self-stretch bg-border flex-shrink-0" />
|
||||
|
||||
{/* Group content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-sm font-medium">{t('group_skattekonto_title')}</p>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] flex-shrink-0',
|
||||
overdue ? 'text-destructive font-medium' : 'text-muted-foreground/60',
|
||||
)}
|
||||
>
|
||||
{t('group_same_day', { count: deadlines.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
{deadlines.map((deadline) => (
|
||||
<div
|
||||
key={deadline.id}
|
||||
onClick={() => {
|
||||
if (!confirmingId && onEdit) onEdit(deadline)
|
||||
}}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-md py-1.5 -mx-2 px-2 transition-colors',
|
||||
onEdit && !confirmingId && 'cursor-pointer hover:bg-accent/40',
|
||||
)}
|
||||
>
|
||||
<p className="text-sm truncate flex-1 min-w-0">{deadline.title}</p>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConfirmingId(deadline.id)
|
||||
}}
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground hover:text-foreground transition-colors px-2.5 py-1 rounded-md border border-border hover:border-foreground/30 hover:bg-accent flex-shrink-0',
|
||||
confirmingId && 'invisible',
|
||||
)}
|
||||
>
|
||||
{t('group_mark_done')}
|
||||
</button>
|
||||
{onEdit && !confirmingId && (
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground/0 group-hover:text-muted-foreground/50 transition-colors flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline confirmation bar (one at a time, mirrors DeadlineCard) */}
|
||||
<div
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm leading-6">
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{deadlineDateLabel(first.due_date)}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50"> · </span>
|
||||
<span>{t('group_skattekonto_title')}</span>
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'grid transition-all duration-200 ease-out',
|
||||
confirming ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||
'text-xs leading-5',
|
||||
overdue ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{confirming && (
|
||||
<div className="border-t px-4 py-3 flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t.rich('group_confirm_question', {
|
||||
title: confirming.title,
|
||||
hl: (chunks) => (
|
||||
<span className="font-medium text-foreground">{chunks}</span>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => setConfirmingId(null)}
|
||||
>
|
||||
{t('group_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => {
|
||||
setConfirmingId(null)
|
||||
onToggle(confirming)
|
||||
}}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('group_confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{t('group_same_day', { count: deadlines.length })}
|
||||
</p>
|
||||
|
||||
<div className="mt-1">
|
||||
{deadlines.map((deadline) => (
|
||||
<div
|
||||
key={deadline.id}
|
||||
onClick={() => onEdit?.(deadline)}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-md py-1.5 -mx-2 px-2 transition-colors duration-150',
|
||||
onEdit && 'cursor-pointer hover:bg-secondary/35',
|
||||
)}
|
||||
>
|
||||
<p className="min-w-0 flex-1 truncate text-sm">{deadline.title}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRequestToggle(deadline)
|
||||
}}
|
||||
className={cn(
|
||||
QUIET_LINK_CLASS,
|
||||
'shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100',
|
||||
)}
|
||||
>
|
||||
{t('group_mark_done')}
|
||||
</button>
|
||||
{onEdit && (
|
||||
<Pencil
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,47 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Deadline, DeadlineType } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DeadlineCard } from './DeadlineCard'
|
||||
import { DeadlineRow, deadlineDateLabel } from './DeadlineRow'
|
||||
import { DeadlineGroupCard, isSkattekontoDeadline } from './DeadlineGroupCard'
|
||||
import { DeadlineFilters } from './DeadlineFilters'
|
||||
import { DeadlineForm } from './DeadlineForm'
|
||||
import { isDeadlineOverdue } from '@/lib/calendar/utils'
|
||||
import { Plus, Lock } from 'lucide-react'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { isDeadlineOverdue, parseDate, startOfDay } from '@/lib/calendar/utils'
|
||||
|
||||
type TypeSegment = 'all' | 'tax' | 'invoicing' | 'own'
|
||||
|
||||
const SEGMENT_TYPES: Record<Exclude<TypeSegment, 'all'>, ReadonlyArray<DeadlineType>> = {
|
||||
tax: ['tax'],
|
||||
invoicing: ['invoicing'],
|
||||
own: ['delivery', 'report', 'other'],
|
||||
}
|
||||
|
||||
interface DeadlineListProps {
|
||||
deadlines: Deadline[]
|
||||
customers: { id: string; name: string }[]
|
||||
onDeadlineCreate: (data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>) => Promise<void>
|
||||
onDeadlineToggle: (deadline: Deadline) => Promise<void>
|
||||
onDeadlineEdit: (deadline: Deadline) => Promise<void>
|
||||
onDeadlineDelete: (deadline: Deadline) => Promise<void>
|
||||
onDeadlineEdit: (deadline: Deadline) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Viktiga datum as the concept thread (scene 17): a type seg, borderless
|
||||
* rows under hairline section headers, and a "Närmast" countdown pane on the
|
||||
* right. Marking done confirms up front via ConfirmDialog.
|
||||
*/
|
||||
export function DeadlineList({
|
||||
deadlines,
|
||||
customers,
|
||||
onDeadlineCreate,
|
||||
onDeadlineToggle,
|
||||
onDeadlineEdit,
|
||||
onDeadlineDelete,
|
||||
}: DeadlineListProps) {
|
||||
const { canWrite } = useCanWrite()
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'completed'>('pending')
|
||||
const [typeFilter, setTypeFilter] = useState<DeadlineType | 'all'>('all')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingDeadline, setEditingDeadline] = useState<Deadline | null>(null)
|
||||
const t = useTranslations('deadlines')
|
||||
const [segment, setSegment] = useState<TypeSegment>('all')
|
||||
const [confirmTarget, setConfirmTarget] = useState<Deadline | null>(null)
|
||||
|
||||
const filteredDeadlines = useMemo(() => {
|
||||
return deadlines.filter((d) => {
|
||||
if (statusFilter === 'pending' && d.is_completed) return false
|
||||
if (statusFilter === 'completed' && !d.is_completed) return false
|
||||
if (typeFilter !== 'all' && d.deadline_type !== typeFilter) return false
|
||||
return true
|
||||
})
|
||||
}, [deadlines, statusFilter, typeFilter])
|
||||
if (segment === 'all') return deadlines
|
||||
const types = SEGMENT_TYPES[segment]
|
||||
return deadlines.filter((d) => types.includes(d.deadline_type))
|
||||
}, [deadlines, segment])
|
||||
|
||||
const groupedDeadlines = useMemo(() => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
@@ -65,41 +64,46 @@ export function DeadlineList({
|
||||
return { overdue, today: todayDeadlines, upcoming, completed }
|
||||
}, [filteredDeadlines])
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setStatusFilter('all')
|
||||
setTypeFilter('all')
|
||||
}
|
||||
// "Närmast" pane: days to the nearest pending deadline (across the
|
||||
// unfiltered list: the countdown is page context, not a filter readout).
|
||||
const nearest = useMemo(() => {
|
||||
const pending = deadlines
|
||||
.filter((d) => !d.is_completed && !isDeadlineOverdue(d))
|
||||
.sort((a, b) => a.due_date.localeCompare(b.due_date))
|
||||
const first = pending[0]
|
||||
if (!first) return null
|
||||
const today = startOfDay(new Date())
|
||||
const days = Math.round(
|
||||
(parseDate(first.due_date).getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
|
||||
)
|
||||
return { deadline: first, days: Math.max(0, days) }
|
||||
}, [deadlines])
|
||||
|
||||
const handleFormSubmit = async (data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>) => {
|
||||
if (editingDeadline) {
|
||||
await onDeadlineEdit({ ...editingDeadline, ...data })
|
||||
} else {
|
||||
await onDeadlineCreate(data)
|
||||
const overdueCount = useMemo(
|
||||
() => deadlines.filter((d) => !d.is_completed && isDeadlineOverdue(d)).length,
|
||||
[deadlines],
|
||||
)
|
||||
|
||||
const handleRequestToggle = (deadline: Deadline) => {
|
||||
// Un-completing is a harmless revert: no confirmation (matches the undo
|
||||
// toast the page already offers).
|
||||
if (deadline.is_completed) {
|
||||
void onDeadlineToggle(deadline)
|
||||
return
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingDeadline(null)
|
||||
}
|
||||
|
||||
const handleEdit = (deadline: Deadline) => {
|
||||
setEditingDeadline(deadline)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleFormClose = () => {
|
||||
setShowForm(false)
|
||||
setEditingDeadline(null)
|
||||
setConfirmTarget(deadline)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ key: 'overdue', label: 'Förfallna', items: groupedDeadlines.overdue },
|
||||
{ key: 'today', label: 'Idag', items: groupedDeadlines.today },
|
||||
{ key: 'upcoming', label: 'Kommande', items: groupedDeadlines.upcoming },
|
||||
{ key: 'completed', label: 'Klara', items: groupedDeadlines.completed },
|
||||
].filter(s => s.items.length > 0)
|
||||
{ key: 'overdue', label: t('section_overdue'), items: groupedDeadlines.overdue },
|
||||
{ key: 'today', label: t('section_today'), items: groupedDeadlines.today },
|
||||
{ key: 'upcoming', label: t('section_upcoming'), items: groupedDeadlines.upcoming },
|
||||
{ key: 'completed', label: t('section_completed'), items: groupedDeadlines.completed },
|
||||
].filter((s) => s.items.length > 0)
|
||||
|
||||
// Skattekonto obligations legally share the same due date (moms + AGI +
|
||||
// preliminärskatt all fall on "den 12:e"): collapse 2+ such rows into one
|
||||
// grouped card per date instead of near-identical rows. Other deadlines
|
||||
// grouped row per date instead of near-identical rows. Other deadlines
|
||||
// render as before, in their original order.
|
||||
type ListEntry =
|
||||
| { kind: 'single'; deadline: Deadline }
|
||||
@@ -131,103 +135,125 @@ export function DeadlineList({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<DeadlineFilters
|
||||
status={statusFilter}
|
||||
type={typeFilter}
|
||||
onStatusChange={setStatusFilter}
|
||||
onTypeChange={setTypeFilter}
|
||||
onReset={handleResetFilters}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setShowForm(true)}
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
>
|
||||
{canWrite ? (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Ny deadline
|
||||
</Button>
|
||||
{/* Toolbar: the type seg (concept: Alla / Skatt / Fakturering / Egna) */}
|
||||
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
|
||||
{(
|
||||
[
|
||||
{ key: 'all', label: t('seg_all') },
|
||||
{ key: 'tax', label: t('seg_tax') },
|
||||
{ key: 'invoicing', label: t('seg_invoicing') },
|
||||
{ key: 'own', label: t('seg_own') },
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={segment === key}
|
||||
onClick={() => setSegment(key)}
|
||||
className={`rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
|
||||
segment === key
|
||||
? 'border border-border bg-card font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{filteredDeadlines.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{statusFilter !== 'all' || typeFilter !== 'all'
|
||||
? 'Inga deadlines matchar filtret.'
|
||||
: 'Inga deadlines ännu.'}
|
||||
<div className="gap-8 lg:grid lg:grid-cols-[minmax(0,1fr)_240px] lg:items-start">
|
||||
{/* The thread */}
|
||||
{filteredDeadlines.length === 0 ? (
|
||||
<p className="py-16 text-center text-sm text-muted-foreground">
|
||||
{segment !== 'all' ? t('empty_filtered') : t('empty_title')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => setShowForm(true)}
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
>
|
||||
{canWrite ? (
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
) : (
|
||||
<Lock className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Skapa en deadline
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{sections.map(({ key, label, items }) => (
|
||||
<section key={key}>
|
||||
<div className="flex items-center gap-3 mb-2 px-1">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</h3>
|
||||
<span className="text-xs tabular-nums text-muted-foreground/50">
|
||||
{items.length}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border/60" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{toEntries(items).map((entry) =>
|
||||
entry.kind === 'group' ? (
|
||||
<DeadlineGroupCard
|
||||
key={`skattekonto-${entry.date}`}
|
||||
deadlines={entry.items}
|
||||
onToggle={onDeadlineToggle}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
) : (
|
||||
<DeadlineCard
|
||||
key={entry.deadline.id}
|
||||
deadline={entry.deadline}
|
||||
onToggle={onDeadlineToggle}
|
||||
onEdit={handleEdit}
|
||||
onDelete={onDeadlineDelete}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="stagger-enter space-y-8">
|
||||
{sections.map(({ key, label, items }) => (
|
||||
<section key={key}>
|
||||
<div className="mb-1 flex items-center gap-3 px-1">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</h2>
|
||||
<span className="text-xs tabular-nums text-muted-foreground/50">
|
||||
{items.length}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
</div>
|
||||
<div>
|
||||
{toEntries(items).map((entry) =>
|
||||
entry.kind === 'group' ? (
|
||||
<DeadlineGroupCard
|
||||
key={`skattekonto-${entry.date}`}
|
||||
deadlines={entry.items}
|
||||
onEdit={onDeadlineEdit}
|
||||
onRequestToggle={handleRequestToggle}
|
||||
/>
|
||||
) : (
|
||||
<DeadlineRow
|
||||
key={entry.deadline.id}
|
||||
deadline={entry.deadline}
|
||||
onEdit={onDeadlineEdit}
|
||||
onRequestToggle={handleRequestToggle}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeadlineForm
|
||||
open={showForm}
|
||||
onOpenChange={handleFormClose}
|
||||
onSubmit={handleFormSubmit}
|
||||
onDelete={(deadline) => {
|
||||
if (deadline.id) {
|
||||
const full = deadlines.find(d => d.id === deadline.id)
|
||||
if (full) onDeadlineDelete(full)
|
||||
}
|
||||
{/* "Närmast" pane (concept aside) */}
|
||||
<aside className="mt-8 lg:mt-0">
|
||||
<div className="rounded-lg border border-border">
|
||||
<div className="border-b border-border px-4 py-2.5">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('nearest_title')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-6 text-center">
|
||||
{nearest ? (
|
||||
<>
|
||||
<p className="font-display text-4xl leading-none tracking-tight tabular-nums">
|
||||
{nearest.days === 0 ? t('rel_today') : nearest.days}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{nearest.days === 0
|
||||
? nearest.deadline.title
|
||||
: t('nearest_days_to', { title: nearest.deadline.title })}
|
||||
</p>
|
||||
<p className="mt-1 text-xs tabular-nums text-muted-foreground/60">
|
||||
{deadlineDateLabel(nearest.deadline.due_date)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="py-4 text-xs text-muted-foreground">{t('nearest_empty')}</p>
|
||||
)}
|
||||
{overdueCount > 0 && (
|
||||
<p className="mt-3 text-xs font-medium text-destructive">
|
||||
{t('nearest_overdue', { count: overdueCount })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmTarget !== null}
|
||||
onOpenChange={(open) => !open && setConfirmTarget(null)}
|
||||
title={t('confirm_done_title')}
|
||||
description={
|
||||
confirmTarget
|
||||
? `${deadlineDateLabel(confirmTarget.due_date)} · ${confirmTarget.title}`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel={t('group_confirm')}
|
||||
onConfirm={async () => {
|
||||
if (confirmTarget) await onDeadlineToggle(confirmTarget)
|
||||
}}
|
||||
initialData={editingDeadline || undefined}
|
||||
customers={customers}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Deadline } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isDeadlineOverdue, parseDate, startOfDay } from '@/lib/calendar/utils'
|
||||
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import {
|
||||
CalendarClock,
|
||||
Check,
|
||||
FileText,
|
||||
Landmark,
|
||||
Pencil,
|
||||
ReceiptText,
|
||||
Truck,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface DeadlineRowProps {
|
||||
deadline: Deadline
|
||||
/** Row click: open the edit dialog. */
|
||||
onEdit?: (deadline: Deadline) => void
|
||||
/** "Markera klar" (or un-done): the list confirms before toggling. */
|
||||
onRequestToggle: (deadline: Deadline) => void
|
||||
}
|
||||
|
||||
function daysFromToday(dateStr: string): number {
|
||||
const today = startOfDay(new Date())
|
||||
const target = parseDate(dateStr)
|
||||
return Math.round((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<Deadline['deadline_type'], typeof CalendarClock> = {
|
||||
tax: Landmark,
|
||||
invoicing: ReceiptText,
|
||||
delivery: Truck,
|
||||
report: FileText,
|
||||
other: CalendarClock,
|
||||
}
|
||||
|
||||
// Statutory deadlines carry the receiving authority's mark: Bolagsverket for
|
||||
// the company-law dates, Skatteverket for everything tax-shaped. Non-system
|
||||
// deadlines keep the neutral type icons.
|
||||
const BOLAGSVERKET_TYPES = new Set(['arsredovisning', 'arsstamma'])
|
||||
|
||||
export function deadlineAuthorityLogo(deadline: Deadline): { src: string; alt: string } | null {
|
||||
if (deadline.tax_deadline_type) {
|
||||
return BOLAGSVERKET_TYPES.has(deadline.tax_deadline_type)
|
||||
? { src: '/logos/bolagsverket.jpg', alt: 'Bolagsverket' }
|
||||
: { src: '/logos/skatteverket_color.svg', alt: 'Skatteverket' }
|
||||
}
|
||||
// Manual deadlines (including user-created tax-category ones) keep the
|
||||
// neutral type icons: the authority crest is reserved for statutory dates
|
||||
// the system generated from the tax settings.
|
||||
return null
|
||||
}
|
||||
|
||||
/** The shared 28px round mark: authority logo on white, or a muted type icon. */
|
||||
export function DeadlineMark({
|
||||
deadline,
|
||||
completed = false,
|
||||
}: {
|
||||
deadline: Deadline
|
||||
completed?: boolean
|
||||
}) {
|
||||
const logo = deadlineAuthorityLogo(deadline)
|
||||
if (completed) {
|
||||
return (
|
||||
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border text-muted-foreground/60">
|
||||
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (logo) {
|
||||
// Deliberately white in both themes: the authority marks are badges, not
|
||||
// chrome, and their brand colors need a white ground.
|
||||
return (
|
||||
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-white">
|
||||
<img src={logo.src} alt={logo.alt} className="h-4 w-4 object-contain" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const Icon = TYPE_ICONS[deadline.deadline_type] ?? CalendarClock
|
||||
return (
|
||||
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const SWEDISH_MONTHS_SHORT = [
|
||||
'jan', 'feb', 'mar', 'apr', 'maj', 'jun',
|
||||
'jul', 'aug', 'sep', 'okt', 'nov', 'dec',
|
||||
]
|
||||
|
||||
export function deadlineDateLabel(dateStr: string): string {
|
||||
const date = parseDate(dateStr)
|
||||
return `${date.getDate()} ${SWEDISH_MONTHS_SHORT[date.getMonth()]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* One deadline as a concept thread row (scene 17): icon circle, "12 aug ·
|
||||
* Title" line, muted status sentence below, quiet hover actions. Rows are
|
||||
* borderless; the section headers carry the structure.
|
||||
*/
|
||||
export function DeadlineRow({ deadline, onEdit, onRequestToggle }: DeadlineRowProps) {
|
||||
const t = useTranslations('deadlines')
|
||||
const overdue = isDeadlineOverdue(deadline)
|
||||
const completed = deadline.is_completed
|
||||
const diff = daysFromToday(deadline.due_date)
|
||||
|
||||
const relative = completed
|
||||
? null
|
||||
: diff === 0
|
||||
? t('rel_today')
|
||||
: diff === 1
|
||||
? t('rel_tomorrow')
|
||||
: diff < 0
|
||||
? t('rel_days_ago', { count: Math.abs(diff) })
|
||||
: diff <= 14
|
||||
? t('rel_in_days', { count: diff })
|
||||
: null
|
||||
|
||||
const subParts = [
|
||||
relative,
|
||||
deadline.due_time ? `kl ${deadline.due_time.slice(0, 5)}` : null,
|
||||
deadline.customer?.name ?? null,
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onEdit?.(deadline)}
|
||||
className={cn(
|
||||
'group flex items-start gap-3 rounded-md py-3 -mx-2 px-2 transition-colors duration-150',
|
||||
onEdit && 'cursor-pointer hover:bg-secondary/35',
|
||||
completed && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<DeadlineMark deadline={deadline} completed={completed} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('text-sm leading-6', completed && 'line-through text-muted-foreground')}>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{deadlineDateLabel(deadline.due_date)}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50"> · </span>
|
||||
<span>{deadline.title}</span>
|
||||
</p>
|
||||
{subParts.length > 0 && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs leading-5',
|
||||
overdue && !completed ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{subParts.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-3 pt-1">
|
||||
{completed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRequestToggle(deadline)
|
||||
}}
|
||||
className={cn(QUIET_LINK_CLASS, 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100')}
|
||||
>
|
||||
{t('mark_not_done')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRequestToggle(deadline)
|
||||
}}
|
||||
className={cn(QUIET_LINK_CLASS, 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100')}
|
||||
>
|
||||
{t('group_mark_done')}
|
||||
</button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Pencil
|
||||
className="h-3.5 w-3.5 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export { DeadlineCard } from './DeadlineCard'
|
||||
export { DeadlineFilters } from './DeadlineFilters'
|
||||
export { DeadlineRow } from './DeadlineRow'
|
||||
export { DeadlineForm } from './DeadlineForm'
|
||||
export { DeadlineList } from './DeadlineList'
|
||||
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
|
||||
|
||||
@@ -537,7 +537,7 @@
|
||||
"history_reason": "reason: \"{reason}\""
|
||||
},
|
||||
"deadlines": {
|
||||
"title": "Deadlines",
|
||||
"title": "Key dates",
|
||||
"load_failed_title": "Could not load deadlines",
|
||||
"load_failed_description": "Check your connection and try again.",
|
||||
"created_title": "Deadline created",
|
||||
@@ -570,7 +570,32 @@
|
||||
"group_mark_done": "Mark as done",
|
||||
"group_confirm_question": "Mark <hl>{title}</hl> as done?",
|
||||
"group_cancel": "Cancel",
|
||||
"group_confirm": "Confirm"
|
||||
"group_confirm": "Confirm",
|
||||
"new_deadline": "New deadline",
|
||||
"read_only_tooltip": "You have read-only access in this company",
|
||||
"generating": "Generating…",
|
||||
"help_text": "Deadlines for VAT, employer declarations and F-tax are generated automatically from your company's tax settings. Add your own with New deadline; click a row to edit it.",
|
||||
"overdue_invoices_action": "View the invoices",
|
||||
"seg_all": "All",
|
||||
"seg_tax": "Tax",
|
||||
"seg_invoicing": "Invoicing",
|
||||
"seg_own": "Your own",
|
||||
"section_overdue": "Overdue",
|
||||
"section_today": "Today",
|
||||
"section_upcoming": "Upcoming",
|
||||
"section_completed": "Done",
|
||||
"nearest_title": "Next up",
|
||||
"nearest_days_to": "days until {title}",
|
||||
"nearest_empty": "Nothing coming up.",
|
||||
"nearest_overdue": "{count, plural, one {1 overdue} other {# overdue}}",
|
||||
"rel_today": "Today",
|
||||
"rel_tomorrow": "Tomorrow",
|
||||
"rel_days_ago": "{count, plural, one {1 day ago} other {# days ago}}",
|
||||
"rel_in_days": "{count, plural, one {In 1 day} other {In # days}}",
|
||||
"mark_not_done": "Undo done",
|
||||
"empty_title": "No deadlines yet.",
|
||||
"empty_filtered": "No deadlines match the filter.",
|
||||
"confirm_done_title": "Mark as done?"
|
||||
},
|
||||
"bureau": {
|
||||
"title": "Bureau",
|
||||
@@ -5851,7 +5876,7 @@
|
||||
"missing_rate": "Missing rate"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import",
|
||||
"title": "Import / export",
|
||||
"subtitle": "Import bank transactions or bookkeeping data into your company",
|
||||
"export_title": "Export",
|
||||
"export_subtitle": "Download your bookkeeping as a SIE file or back it up to Google Drive",
|
||||
@@ -5862,28 +5887,33 @@
|
||||
"psd2_title": "Connect bank",
|
||||
"psd2_recommended": "Recommended",
|
||||
"psd2_requires_subscription": "Requires subscription",
|
||||
"psd2_description": "Connect your bank account directly and sync transactions automatically via PSD2.",
|
||||
"psd2_description": "Automatic bank transaction sync via PSD2",
|
||||
"migration_title": "Import from another system",
|
||||
"migration_description": "Nothing changes in your existing system.",
|
||||
"bankfile_title": "Bank transactions",
|
||||
"bankfile_description": "Import statements from your bank. Supports most Swedish banks.",
|
||||
"csv_data_title": "Import CSV / Excel data",
|
||||
"csv_data_description": "Import opening balances, customers, suppliers, or articles.",
|
||||
"bankfile_title": "Bank file",
|
||||
"bankfile_description": "CSV, OFX or your bank's own formats: SEB, Swedbank, Nordea",
|
||||
"csv_data_title": "CSV or Excel",
|
||||
"csv_data_description": "Opening balances, customers, suppliers and articles",
|
||||
"csv_chip_opening_balances": "Opening balances",
|
||||
"csv_chip_customers": "Customers",
|
||||
"csv_chip_suppliers": "Suppliers",
|
||||
"csv_chip_articles": "Articles",
|
||||
"sie_title": "Bookkeeping data (SIE)",
|
||||
"sie_description": "Import journal entries and chart of accounts from another bookkeeping system.",
|
||||
"sie_title": "SIE file",
|
||||
"sie_description": "Bookkeeping from another system or from your accountant",
|
||||
"loading_migration": "Loading migration tool...",
|
||||
"export_heading": "Export",
|
||||
"export_sie_title": "Export SIE file",
|
||||
"export_sie_description": "Export your bookkeeping as a SIE4 file for your accountant or for migrating to another system.",
|
||||
"export_sie_title": "SIE 4",
|
||||
"export_sie_description": "Any fiscal year, with or without closing entries",
|
||||
"export_sie_period_label": "Fiscal year",
|
||||
"export_sie_button": "Download SIE",
|
||||
"export_sie_no_period": "Select a fiscal year to export.",
|
||||
"export_sie_exclude_closing_label": "Exclude year-end closing voucher (recommended for eDeklarera and systems that do their own year-end closing)",
|
||||
"export_cloud_title": "Cloud backup to Google Drive"
|
||||
"export_cloud_title": "Cloud backup to Google Drive",
|
||||
"export_sie_dialog_description": "Download your bookkeeping as a SIE4 file for your accountant or for moving to another system.",
|
||||
"cloud_row_title": "Cloud sync",
|
||||
"cloud_row_description": "Continuous backup of the archive to Google Drive",
|
||||
"help_text": "Every way in and out lives here: bank connections, file imports and migrations from other systems, plus SIE export and backups. Every import is reviewed before anything is booked.",
|
||||
"pgnote": "Every import goes through the same steps: upload, map columns, review, result. Nothing is booked without you seeing it first."
|
||||
},
|
||||
"annualReportStudio": {
|
||||
"choose": "Välj",
|
||||
@@ -5986,5 +6016,28 @@
|
||||
"preset_reports_description": "Reports are generated automatically once you have enough data. Start by creating invoices or importing transactions.",
|
||||
"preset_reports_action": "Create invoice",
|
||||
"preset_reports_secondary": "Import transactions"
|
||||
},
|
||||
"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",
|
||||
"attn_reconnect_action": "Reconnect",
|
||||
"attn_shortfall": "The charge on {date} is {charge}. The balance is {missing} short.",
|
||||
"attn_show_payment": "Show payment details",
|
||||
"band_upcoming": "Upcoming",
|
||||
"band_overdue": "Overdue",
|
||||
"band_booked": "Completed",
|
||||
"no_transactions": "No transactions yet. Sync to fetch the tax account.",
|
||||
"chip_not_booked": "Not booked",
|
||||
"chip_possible_duplicate": "Possible duplicate of {voucher}",
|
||||
"chip_draft": "draft",
|
||||
"action_show_voucher": "Show voucher",
|
||||
"action_match": "Match",
|
||||
"action_book": "Book",
|
||||
"action_booking": "Booking…",
|
||||
"payment_title": "Payment details",
|
||||
"payment_description": "The charge on {date} is {charge}.",
|
||||
"payment_bankgiro_label": "Bankgiro (Skatteverket)",
|
||||
"payment_shortfall_label": "Missing on the account",
|
||||
"pgnote": "Synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically, and deviations are flagged here. Kronofogden: {amount}."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,7 +537,7 @@
|
||||
"history_reason": "orsak: \"{reason}\""
|
||||
},
|
||||
"deadlines": {
|
||||
"title": "Deadlines",
|
||||
"title": "Viktiga datum",
|
||||
"load_failed_title": "Kunde inte ladda deadlines",
|
||||
"load_failed_description": "Kontrollera din anslutning och försök igen.",
|
||||
"created_title": "Deadline skapad",
|
||||
@@ -570,7 +570,32 @@
|
||||
"group_mark_done": "Markera klar",
|
||||
"group_confirm_question": "Markera <hl>{title}</hl> som klar?",
|
||||
"group_cancel": "Avbryt",
|
||||
"group_confirm": "Bekräfta"
|
||||
"group_confirm": "Bekräfta",
|
||||
"new_deadline": "Ny deadline",
|
||||
"read_only_tooltip": "Du har endast läsbehörighet i detta företag",
|
||||
"generating": "Genererar…",
|
||||
"help_text": "Deadlines för moms, arbetsgivardeklaration och F-skatt skapas automatiskt från företagets skatteinställningar. Egna deadlines lägger du till med Ny deadline; klicka på en rad för att ändra den.",
|
||||
"overdue_invoices_action": "Visa fakturorna",
|
||||
"seg_all": "Alla",
|
||||
"seg_tax": "Skatt",
|
||||
"seg_invoicing": "Fakturering",
|
||||
"seg_own": "Egna",
|
||||
"section_overdue": "Förfallna",
|
||||
"section_today": "Idag",
|
||||
"section_upcoming": "Kommande",
|
||||
"section_completed": "Klara",
|
||||
"nearest_title": "Närmast",
|
||||
"nearest_days_to": "dagar till {title}",
|
||||
"nearest_empty": "Inget på gång.",
|
||||
"nearest_overdue": "{count, plural, one {1 förfallen} other {# förfallna}}",
|
||||
"rel_today": "Idag",
|
||||
"rel_tomorrow": "Imorgon",
|
||||
"rel_days_ago": "{count, plural, one {1 dag sedan} other {# dagar sedan}}",
|
||||
"rel_in_days": "{count, plural, one {Om 1 dag} other {Om # dagar}}",
|
||||
"mark_not_done": "Ångra klar",
|
||||
"empty_title": "Inga deadlines ännu.",
|
||||
"empty_filtered": "Inga deadlines matchar filtret.",
|
||||
"confirm_done_title": "Markera som klar?"
|
||||
},
|
||||
"bureau": {
|
||||
"title": "Byrå",
|
||||
@@ -5851,7 +5876,7 @@
|
||||
"missing_rate": "Saknar timpris"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importera",
|
||||
"title": "Importera / exportera",
|
||||
"subtitle": "Importera banktransaktioner eller bokföringsdata till ditt företag",
|
||||
"export_title": "Exportera",
|
||||
"export_subtitle": "Ladda ner bokföringen som SIE-fil eller säkerhetskopia till Google Drive",
|
||||
@@ -5862,28 +5887,33 @@
|
||||
"psd2_title": "Koppla bank",
|
||||
"psd2_recommended": "Rekommenderat",
|
||||
"psd2_requires_subscription": "Kräver abonnemang",
|
||||
"psd2_description": "Anslut ditt bankkonto direkt och synka transaktioner automatiskt via PSD2.",
|
||||
"psd2_description": "Automatisk hämtning av banktransaktioner via PSD2",
|
||||
"migration_title": "Hämta från annat system",
|
||||
"migration_description": "Inget ändras i ditt befintliga system.",
|
||||
"bankfile_title": "Banktransaktioner",
|
||||
"bankfile_description": "Importera kontoutdrag från din bank. Stöder de flesta svenska banker.",
|
||||
"csv_data_title": "Importera CSV/Excel-data",
|
||||
"csv_data_description": "Importera ingående balanser, kunder, leverantörer eller artiklar.",
|
||||
"bankfile_title": "Bankfil",
|
||||
"bankfile_description": "CSV, OFX eller bankens egna format: SEB, Swedbank, Nordea",
|
||||
"csv_data_title": "CSV eller Excel",
|
||||
"csv_data_description": "Ingående balanser, kunder, leverantörer och artiklar",
|
||||
"csv_chip_opening_balances": "Ingående balanser",
|
||||
"csv_chip_customers": "Kunder",
|
||||
"csv_chip_suppliers": "Leverantörer",
|
||||
"csv_chip_articles": "Artiklar",
|
||||
"sie_title": "Bokföringsdata (SIE)",
|
||||
"sie_description": "Importera verifikationer och kontoplan från ett annat bokföringsprogram.",
|
||||
"sie_title": "SIE-fil",
|
||||
"sie_description": "Bokföring från ett annat system eller från revisorn",
|
||||
"loading_migration": "Laddar migreringsverktyg...",
|
||||
"export_heading": "Exportera",
|
||||
"export_sie_title": "Exportera SIE-fil",
|
||||
"export_sie_description": "Exportera bokföringen som SIE4-fil för revisor eller programmigrering.",
|
||||
"export_sie_title": "SIE 4",
|
||||
"export_sie_description": "Valfritt räkenskapsår, med eller utan bokslutsposter",
|
||||
"export_sie_period_label": "Räkenskapsår",
|
||||
"export_sie_button": "Ladda ner SIE",
|
||||
"export_sie_no_period": "Välj ett räkenskapsår för att exportera.",
|
||||
"export_sie_exclude_closing_label": "Exkludera bokslutsverifikat (rekommenderas för eDeklarera och system som gör eget bokslut)",
|
||||
"export_cloud_title": "Säkerhetskopia till Google Drive"
|
||||
"export_cloud_title": "Säkerhetskopia till Google Drive",
|
||||
"export_sie_dialog_description": "Ladda ner bokföringen som SIE4-fil för revisorn eller för flytt till ett annat system.",
|
||||
"cloud_row_title": "Molnsynkronisering",
|
||||
"cloud_row_description": "Löpande säkerhetskopia av arkivet till Google Drive",
|
||||
"help_text": "Här samlas alla vägar in och ut: bankkoppling, filimporter och flytt från andra system, samt export av SIE och säkerhetskopior. Varje import granskas innan något bokförs.",
|
||||
"pgnote": "Varje import går genom samma steg: ladda upp, mappa kolumner, granska, resultat. Inget bokförs utan att du ser det först."
|
||||
},
|
||||
"annualReportStudio": {
|
||||
"choose": "Välj",
|
||||
@@ -5986,5 +6016,28 @@
|
||||
"preset_reports_description": "Rapporter genereras automatiskt när du har tillräckligt med data. Börja med att skapa fakturor eller importera transaktioner.",
|
||||
"preset_reports_action": "Skapa faktura",
|
||||
"preset_reports_secondary": "Importera transaktioner"
|
||||
},
|
||||
"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",
|
||||
"attn_reconnect_action": "Anslut igen",
|
||||
"attn_shortfall": "Dragningen {date} är {charge}. Saldot saknar {missing}.",
|
||||
"attn_show_payment": "Visa betalningsuppgifter",
|
||||
"band_upcoming": "Kommande",
|
||||
"band_overdue": "Förfallna",
|
||||
"band_booked": "Genomförda",
|
||||
"no_transactions": "Inga transaktioner ännu. Synkronisera för att hämta skattekontot.",
|
||||
"chip_not_booked": "Ej bokförd",
|
||||
"chip_possible_duplicate": "Möjlig dublett av {voucher}",
|
||||
"chip_draft": "utkast",
|
||||
"action_show_voucher": "Visa verifikat",
|
||||
"action_match": "Matcha",
|
||||
"action_book": "Bokför",
|
||||
"action_booking": "Bokför…",
|
||||
"payment_title": "Betalningsuppgifter",
|
||||
"payment_description": "Dragningen {date} är {charge}.",
|
||||
"payment_bankgiro_label": "Bankgiro (Skatteverket)",
|
||||
"payment_shortfall_label": "Saknas på kontot",
|
||||
"pgnote": "Synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt, och avvikelser flaggas här. Kronofogden: {amount}."
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,84 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 69.4 63.1" style="enable-background:new 0 0 69.4 63.1;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#231F20;}
|
||||
</style>
|
||||
<metadata>
|
||||
<sfw xmlns="ns_sfw;">
|
||||
<slices>
|
||||
</slices>
|
||||
<sliceSourceBounds bottomLeftOrigin="true" height="63.2" width="69.6" x="-0.1" y="-0.1">
|
||||
</sliceSourceBounds>
|
||||
</sfw>
|
||||
</metadata>
|
||||
<g>
|
||||
<path class="st0" d="M55.5,50.9c2-5.9,1.2-12.6-3.3-17.4c-2.9-3.3-7-5.3-11.2-6.5l0.1-0.7C50.2,28.4,59.3,34,60.9,44
|
||||
c0.3,2.2,0.3,4.6-0.3,6.8L55.5,50.9z">
|
||||
</path>
|
||||
<path class="st0" d="M36.7,29.1c1.6,0.8,3,2,4.1,3.5c3.4,4.8,2.8,9.8,1.7,12.8c-1.1,3.5-3,5.4-3,5.4h-6.9c4.6-3,7.4-7.7,7.7-13.2
|
||||
c0.1-2.9-1.3-6.4-3.9-8L36.7,29.1z">
|
||||
</path>
|
||||
<path class="st0" d="M39.5,4.4c-6.1,1.2-11.4,5.2-13.4,11.5c-1.4,4.1-1.1,8.7-0.1,13l-0.6,0.2c-2.7-9-2.4-19.7,5.6-26
|
||||
c1.8-1.4,3.8-2.5,6-3.1L39.5,4.4z">
|
||||
</path>
|
||||
<path class="st0" d="M30.1,31.5c-0.1-1.8,0.2-3.6,1-5.3c2.4-5.3,7.1-7.3,10.3-7.9c3.6-0.8,6.2-0.1,6.2-0.1l3.4,6
|
||||
c-4.9-2.5-10.3-2.6-15.2-0.1c-2.5,1.4-4.9,4.3-5,7.4H30.1z">
|
||||
</path>
|
||||
<g>
|
||||
<path class="st0" d="M8.1,41.6c4.2,4.6,10.4,7.1,16.7,5.6c4.3-0.9,8.1-3.5,11.1-6.6l0.5,0.4c-6.3,6.9-15.6,12.2-25.1,8.6
|
||||
c-2.1-0.8-4.1-2-5.8-3.6L8.1,41.6z">
|
||||
</path>
|
||||
<path class="st0" d="M36.1,35.7c-1.5,1-3.2,1.7-5.1,1.9c-5.8,0.6-9.9-2.3-12.1-4.7c-2.6-2.6-3.3-5.3-3.3-5.3l3.4-6
|
||||
c0.4,5.5,3.1,10.2,7.8,13.1c2.5,1.4,6.2,2,8.9,0.5L36.1,35.7z">
|
||||
</path>
|
||||
</g>
|
||||
<path class="st0" d="M4.9,55.9c-0.5-0.2-1.1-0.4-1.8-0.4c-0.6,0-1.4,0.3-1.4,1.2c0,1.5,3.8,0.9,3.8,3.8c0,1.9-1.5,2.6-3.2,2.6
|
||||
c-0.9,0-1.4-0.1-2.2-0.3l0.2-1.6c0.6,0.3,1.3,0.5,1.9,0.5c0.7,0,1.5-0.3,1.5-1.1c0-1.6-3.8-1-3.8-3.8c0-1.9,1.5-2.6,3-2.6
|
||||
c0.7,0,1.4,0.1,2.1,0.3L4.9,55.9z">
|
||||
</path>
|
||||
<path class="st0" d="M6.8,53.6h1.7V59l0,0l2-2.5h1.9L10,59.4l2.7,3.5h-2.1l-2.1-3.1l0,0v3.1H6.8V53.6z">
|
||||
</path>
|
||||
<path class="st0" d="M14.4,61.1c0-1,1.1-1.1,1.8-1.1h0.7c0,0.5-0.1,0.9-0.3,1.3c-0.3,0.3-0.7,0.6-1.2,0.6
|
||||
C14.8,61.9,14.4,61.7,14.4,61.1z M13.5,58.4c0.6-0.4,1.3-0.7,2-0.7c1,0,1.4,0.3,1.4,1.4H16c-0.7,0-1.5,0.1-2.1,0.4
|
||||
c-0.6,0.3-1.1,0.9-1.1,1.8c0,1.2,1.1,1.9,2.2,1.9c0.8,0,1.6-0.4,2-1.1l0,0c0,0.2,0,0.6,0.1,0.9h1.5c0-0.5-0.1-0.9-0.1-1.4
|
||||
s0-0.9,0-1.6v-0.8c0-1.9-0.8-2.7-2.7-2.7c-0.7,0-1.6,0.2-2.2,0.5L13.5,58.4z">
|
||||
</path>
|
||||
<path class="st0" d="M20.2,57.8H19v-1.2h1.2v-1.3l1.7-0.5v1.8h1.5v1.2h-1.5v3c0,0.5,0.1,1,0.8,1c0.3,0,0.6-0.1,0.8-0.2v1.3
|
||||
C23.2,63,22.8,63,22.3,63c-1.3,0-2-0.8-2-2.1L20.2,57.8z">
|
||||
</path>
|
||||
<path class="st0" d="M25,57.8h-1.2v-1.2H25v-1.3l1.7-0.5v1.8h1.5v1.2h-1.5v3c0,0.5,0.1,1,0.8,1c0.3,0,0.6-0.1,0.8-0.2v1.3
|
||||
C28,63,27.6,63,27.1,63c-1.3,0-2-0.8-2-2.1L25,57.8z">
|
||||
</path>
|
||||
<path class="st0" d="M30.2,59.1c0.1-0.9,0.5-1.5,1.5-1.5c0.9,0,1.3,0.7,1.3,1.5H30.2z M34.1,61.2c-0.6,0.3-1.3,0.6-2.1,0.6
|
||||
c-1,0-1.7-0.6-1.8-1.6h4.4c0-2.2-0.7-3.8-3-3.8c-2,0-2.9,1.5-3,3.3c0,2.1,1.2,3.3,3.3,3.3c0.9,0,1.6-0.2,2.2-0.5V61.2z">
|
||||
</path>
|
||||
<path class="st0" d="M34.8,56.6h1.8l1.6,4.6l0,0l1.5-4.6h1.7L39,62.9h-1.9L34.8,56.6z">
|
||||
</path>
|
||||
<path class="st0" d="M43.1,59.1c0.1-0.9,0.5-1.5,1.5-1.5c0.9,0,1.3,0.7,1.3,1.5H43.1z M47,61.2c-0.6,0.3-1.3,0.6-2.1,0.6
|
||||
c-1,0-1.7-0.6-1.8-1.6h4.4c0-2.2-0.7-3.8-3-3.8c-2,0-2.9,1.5-3,3.3c0,2.1,1.2,3.3,3.3,3.3c0.9,0,1.6-0.2,2.2-0.5L47,61.2z">
|
||||
</path>
|
||||
<path class="st0" d="M48.7,56.6h1.5V58l0,0c0.1-0.6,0.8-1.6,1.7-1.6c0.2,0,0.3,0,0.5,0v1.7c-0.1,0-0.4-0.1-0.7-0.1
|
||||
c-1.3,0-1.3,1.7-1.3,2.6v2.3h-1.7V56.6z">
|
||||
</path>
|
||||
<path class="st0" d="M53.1,53.7h1.7v5.4l0,0l2-2.5h1.9l-2.4,2.8L59,63h-2.1l-2.1-3.1l0,0V63h-1.7L53.1,53.7z">
|
||||
</path>
|
||||
<path class="st0" d="M60.4,59.2c0.1-0.9,0.5-1.5,1.5-1.5c0.9,0,1.3,0.7,1.3,1.5H60.4z M64.3,61.3c-0.6,0.3-1.3,0.6-2.1,0.6
|
||||
c-1,0-1.7-0.6-1.8-1.6h4.4c0-2.2-0.7-3.8-3-3.8c-2,0-2.9,1.5-3,3.3c0,2.1,1.2,3.3,3.3,3.3c0.9,0,1.6-0.2,2.2-0.5V61.3z">
|
||||
</path>
|
||||
<path class="st0" d="M66.2,57.8H65v-1.2h1.2v-1.3l1.7-0.5v1.8h1.5v1.2h-1.5v3c0,0.5,0.1,1,0.8,1c0.3,0,0.6-0.1,0.8-0.2v1.3
|
||||
C69.2,63,68.8,63,68.3,63c-1.3,0-2-0.8-2-2.1L66.2,57.8z">
|
||||
</path>
|
||||
</g>
|
||||
<path class="st0" d="M38.8,27.8c6.2,1.9,11.5,6.8,12.8,13.5c0.6,3.1,0.1,6.6-1.1,9.7h-5.4c1.3-2.2,2.3-4.6,2.5-7.2
|
||||
c0.4-4.5-1.2-9.9-5.1-12.9c-1.2-1-2.5-1.8-3.9-2.5L38.8,27.8z">
|
||||
</path>
|
||||
<path class="st0" d="M27.9,30.4c-1.5-6.3,0.2-13.3,5.3-17.8c2.4-2.1,5.6-3.4,8.9-3.9l2.7,4.7c-2.6,0-5.1,0.3-7.5,1.4
|
||||
c-4.2,1.9-8,6-8.6,10.8c-0.2,1.5-0.3,3.1-0.2,4.6L27.9,30.4z">
|
||||
</path>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st0" d="M36.3,38.2c-4.6,4.5-11.5,6.7-18,4.7c-3-1-5.8-3.1-7.9-5.6l2.6-4.7c1.3,2.2,2.9,4.2,5.1,5.7
|
||||
c3.8,2.6,9.3,3.7,13.7,1.8c1.4-0.6,2.8-1.3,4.1-2.3L36.3,38.2z">
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,142 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="87.841667mm"
|
||||
height="79.883453mm"
|
||||
viewBox="0 0 332 301.92171"
|
||||
version="1.1"
|
||||
id="svg1103"
|
||||
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
|
||||
sodipodi:docname="Skatteverket logo.svg">
|
||||
<defs
|
||||
id="defs1097" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.35"
|
||||
inkscape:cx="212.42857"
|
||||
inkscape:cy="130.96085"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
inkscape:document-rotation="0"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1267"
|
||||
inkscape:window-height="728"
|
||||
inkscape:window-x="83"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0" />
|
||||
<metadata
|
||||
id="metadata1100">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-162.57143,-389.03915)">
|
||||
<path
|
||||
style="fill:#ffcc00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 347.99303,521.79612 c 29.72199,8.92318 54.80843,32.42997 61.22108,64.38041 2.88664,15.01615 0.66761,31.37688 -5.02103,46.19555 l -25.90449,-0.0751 c 6.07415,-10.65338 10.78492,-22.02128 11.98845,-34.35764 2.07802,-21.75788 -5.88609,-47.38031 -24.30602,-61.48437 -5.78266,-4.60734 -11.89444,-8.71633 -18.89006,-11.72521 l 0.91207,-2.93364"
|
||||
id="path37" />
|
||||
<path
|
||||
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 427.82209,632.29686 c 9.77883,-28.13291 5.57583,-60.09276 -15.5897,-83.09182 -13.8408,-15.63671 -33.52069,-25.55659 -53.52026,-31.20761 l 0.61117,-3.21573 c 43.52519,10.0609 87.07857,37.05611 94.29047,84.88774 1.67367,10.71909 1.41979,22.03996 -1.45742,32.64625 l -24.33426,-0.0193"
|
||||
id="path39" />
|
||||
<path
|
||||
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 338.13898,528.33102 c 7.57858,4.04316 14.34854,9.60016 19.79272,16.81206 16.17267,22.78278 13.32366,46.79732 8.34021,61.44675 -5.04927,16.76505 -14.43317,25.70703 -14.43317,25.70703 l -33.09756,0.10344 c 21.92713,-14.21691 35.21316,-36.71761 36.70821,-62.99822 0.24445,-13.75618 -6.16819,-30.71868 -18.53277,-38.41011 l 1.22236,-2.66102"
|
||||
id="path41" />
|
||||
<path
|
||||
style="fill:#ffcc00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 295.84556,534.37695 c -7.08966,-30.21092 0.76161,-63.68461 25.2557,-85.17922 11.57475,-9.99509 26.86355,-16.23849 42.5379,-18.69259 l 12.86292,22.48189 c -12.26116,0.0469 -24.4753,1.63607 -35.75854,6.75114 -19.89616,9.05483 -38.11863,28.74412 -41.15571,51.74317 -1.10951,7.30592 -1.62666,14.64943 -0.74281,22.21862 l -2.99946,0.67699"
|
||||
id="path43" />
|
||||
<path
|
||||
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 351.56607,410.18586 c -29.25187,5.557 -54.63921,25.02063 -64.01372,54.84606 -6.64772,19.79272 -5.42536,41.79508 -0.3385,61.94511 l -3.09349,1.0813 c -12.99455,-42.74476 -11.33968,-93.96136 26.52507,-124.07826 8.45304,-6.78877 18.24128,-12.13892 28.87575,-14.94092 l 12.04489,21.14671"
|
||||
id="path45" />
|
||||
<path
|
||||
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 306.43302,539.65189 c -0.28208,-8.58469 1.15652,-17.22579 4.69196,-25.54719 11.67817,-25.36854 33.90618,-34.89349 49.09158,-37.87415 17.04713,-3.98675 29.61856,-0.39492 29.61856,-0.39492 l 16.46417,28.7065 c -23.26233,-11.91325 -49.39249,-12.19533 -72.90868,-0.38551 -12.04489,6.65713 -23.55382,20.67659 -24.04276,35.232 l -2.91483,0.26327"
|
||||
id="path47" />
|
||||
<path
|
||||
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 201.24481,588.01007 c 19.84915,22.19981 49.61815,33.91561 79.99832,26.56268 20.3945,-4.5039 38.61697,-16.90608 53.25699,-31.64955 l 2.51994,2.09681 c -29.91945,33.17279 -74.65758,58.16521 -119.98807,41.25913 -10.17375,-3.74229 -19.81153,-9.37451 -27.68162,-17.03772 l 11.89444,-21.23135"
|
||||
id="path49" />
|
||||
<path
|
||||
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 335.4498,559.91475 c -7.21189,4.67315 -15.35463,7.89828 -24.30603,9.15823 -27.76623,3.0747 -47.38029,-11.05759 -57.77029,-22.5289 C 241.16876,533.99144 237.76497,521.373 237.76497,521.373 l 16.10687,-28.91337 c 1.78651,26.07375 15.01611,48.61207 37.26295,62.66912 11.91325,6.8922 29.85364,9.51556 42.58491,2.43531 l 1.7301,2.35069"
|
||||
id="path51" />
|
||||
<path
|
||||
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 186.05003,656.17036 c -2.59515,-1.12832 -5.48179,-1.7771 -8.37783,-1.78649 -2.82082,0 -6.48788,1.23173 -6.48788,5.71686 -0.009,7.13665 18.03443,4.14657 18.01562,17.94977 -0.009,9.02661 -7.14606,12.4304 -15.46748,12.42098 -4.48509,0 -6.48788,-0.59236 -10.54985,-1.60786 l 0.77102,-7.42814 c 2.83022,1.54203 6.07417,2.54814 9.31809,2.54814 3.12171,0.009 7.31533,-1.57966 7.32472,-5.29374 0.009,-7.84185 -18.04381,-4.62612 -18.025,-18.25067 0.009,-9.19586 7.15546,-12.55262 14.40497,-12.54323 3.53541,0.009 6.84516,0.47954 9.84463,1.49504 l -0.77101,6.77934"
|
||||
id="path53"
|
||||
clip-path="none" />
|
||||
<path
|
||||
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 195.02963,645.5641 7.89828,0.009 -0.0376,26.00792 h 0.12222 l 9.44972,-12.13891 9.3181,0.0193 -11.33968,13.54932 12.71248,16.82146 -10.02331,-0.009 -10.12672,-14.76227 h -0.11283 l -0.0188,14.74347 -7.90769,-0.009 0.0659,-44.23039"
|
||||
id="path57"
|
||||
clip-path="none" />
|
||||
<path
|
||||
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 227.21512,668.24347 c 2.66097,-2.00277 6.02714,-3.29096 9.38392,-3.29096 4.66374,0.009 6.83578,1.66429 6.83578,6.56309 l -4.36287,-0.009 c -3.30975,-0.009 -7.20247,0.28207 -10.21135,1.74891 -3.00887,1.47621 -5.1903,4.12778 -5.1997,8.6693 -0.009,5.77327 5.23731,9.02661 10.60626,9.03601 3.59183,0.009 7.55038,-1.88055 9.38392,-5.23731 h 0.11283 c 0.0563,0.94967 0.0563,2.95244 0.34791,4.54152 l 6.958,0.009 c -0.16916,-2.36008 -0.28209,-4.48509 -0.3385,-6.77934 -0.0563,-2.23786 -0.11283,-4.54153 -0.11283,-7.60682 l 0.009,-3.89272 c 0.009,-8.9702 -3.8175,-12.80651 -13.07918,-12.81591 -3.35676,-0.009 -7.48455,0.87444 -10.55925,2.28487 z m 3.99615,13.22023 c 0.009,-4.66374 5.25613,-5.24672 8.79154,-5.23733 h 3.42261 c 0,2.36008 -0.35731,4.5415 -1.65489,6.2528 -1.24116,1.64548 -3.1311,2.70799 -5.557,2.70799 -2.83022,-0.009 -5.00226,-1.12833 -5.00226,-3.72346"
|
||||
id="path59" />
|
||||
<path
|
||||
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 259.03392,665.30042 -5.83908,-0.009 0.009,-5.78268 5.83908,0.009 0.009,-6.07415 7.89828,-2.52933 -0.009,8.61286 7.02384,0.009 -0.009,5.78266 -7.02383,-0.009 -0.0188,14.15108 c 0,2.59515 0.70521,5.01167 3.71407,5.02106 1.41041,0 2.77381,-0.29147 3.59185,-0.82744 l 0.23507,6.25281 c -1.65488,0.47014 -3.4884,0.70521 -5.84848,0.6958 -6.18699,0 -9.60017,-3.8363 -9.59077,-9.85406 l 0.0188,-15.44866"
|
||||
id="path63" />
|
||||
<path
|
||||
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 281.99535,665.32862 -5.8391,-0.009 0.009,-5.78266 5.8391,0.009 0.009,-6.07415 7.90769,-2.51994 -0.009,8.60348 7.01441,0.009 -0.009,5.78266 -7.01442,-0.009 -0.0188,14.15109 c -0.009,2.59515 0.69581,5.01165 3.70467,5.02105 1.41981,0 2.7738,-0.29147 3.60124,-0.82743 l 0.22566,6.2528 c -1.65486,0.47015 -3.479,0.70522 -5.83908,0.70522 -6.19639,-0.009 -9.60958,-3.84572 -9.60018,-9.86347 l 0.0188,-15.44866"
|
||||
id="path67" />
|
||||
<path
|
||||
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 325.89665,681.8398 c -2.95246,1.64548 -6.25281,3.05589 -10.08912,3.05589 -4.88942,-0.009 -8.31201,-2.79262 -8.65991,-7.68203 l 20.99627,0.0282 c 0.0188,-10.61569 -3.15931,-18.34471 -14.42376,-18.36352 -9.43093,-0.009 -14.10408,7.2401 -14.11349,16.02223 -0.0188,9.96689 5.81088,15.75896 15.84358,15.76835 4.24063,0.009 7.55038,-0.80862 10.43702,-2.39768 z m -18.73963,-10.05153 c 0.35731,-4.07136 2.60456,-7.13665 7.03323,-7.12723 4.41929,0.009 6.1776,3.30975 6.41267,7.14606 l -13.4459,-0.0193"
|
||||
id="path71" />
|
||||
<path
|
||||
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 329.01834,659.61177 8.49066,0.009 7.52216,22.13399 h 0.11283 l 7.35292,-22.1152 7.89828,0.0193 -11.24564,30.35199 -8.91377,-0.009 -11.21744,-30.38958"
|
||||
id="path75" />
|
||||
<path
|
||||
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 387.51264,681.93382 c -2.95244,1.64548 -6.2528,3.05589 -10.08911,3.04648 -4.89881,-0.009 -8.31199,-2.78321 -8.65989,-7.67262 l 20.99627,0.0282 c 0.0188,-10.61566 -3.15931,-18.34471 -14.42378,-18.3635 -9.43093,-0.009 -14.10408,7.23067 -14.11349,16.02223 -0.0188,9.96688 5.82029,15.74954 15.83419,15.76835 4.25002,0 7.55038,-0.81805 10.44643,-2.40709 z m -18.7396,-10.06091 c 0.35729,-4.06198 2.60454,-7.12727 7.03323,-7.11785 4.41928,0 6.18698,3.30976 6.41265,7.13666 l -13.44588,-0.0188"
|
||||
id="path79" />
|
||||
<path
|
||||
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 395.37332,659.7058 7.02384,0.009 -0.009,6.9016 h 0.11283 c 0.35731,-2.83022 3.61062,-7.60681 8.3308,-7.59739 0.76163,0 1.58906,0 2.41651,0.24446 l -0.009,7.9547 c -0.7052,-0.41372 -2.12501,-0.64878 -3.54482,-0.64878 -6.42206,-0.009 -6.44085,8.0111 -6.44085,12.37397 l -0.0188,11.14223 -7.89828,-0.009 0.0376,-30.37078"
|
||||
id="path83" />
|
||||
<path
|
||||
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 416.7551,645.87439 7.90768,0.009 -0.0376,26.01731 h 0.11284 l 9.45913,-12.13891 9.31809,0.009 -11.34908,13.5493 12.71248,16.83088 -10.02329,-0.0193 -10.11733,-14.76226 h -0.12222 l -0.0193,14.74345 -7.90771,-0.009 0.0657,-44.23039"
|
||||
id="path87" />
|
||||
<path
|
||||
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 470.08733,682.04665 c -2.95248,1.64548 -6.25282,3.05587 -10.08913,3.05587 -4.89882,-0.009 -8.31199,-2.78319 -8.65992,-7.682 l 20.9963,0.0282 c 0.009,-10.61567 -3.15932,-18.34471 -14.42379,-18.36352 -9.44031,-0.009 -14.10406,7.2401 -14.11348,16.02223 -0.0193,9.9669 5.81088,15.75898 15.83419,15.76837 4.25002,0.009 7.55979,-0.80864 10.44642,-2.39768 z m -18.73962,-10.0515 c 0.35731,-4.07137 2.60454,-7.13666 7.03324,-7.12727 4.41925,0.009 6.18699,3.30975 6.41265,7.14606 l -13.44589,-0.0193"
|
||||
id="path91" />
|
||||
<path
|
||||
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 479.15154,665.61072 -5.82967,-0.009 0.009,-5.78266 5.82969,0.009 0.009,-6.07415 7.90769,-2.51994 -0.009,8.6035 7.01443,0.009 -0.009,5.78267 -7.01441,-0.009 -0.0193,14.15111 c 0,2.59515 0.69583,5.01164 3.70468,5.02105 1.41982,0 2.77381,-0.29151 3.60124,-0.82743 l 0.22554,6.25278 c -1.65486,0.47016 -3.479,0.70522 -5.83908,0.70522 -6.18698,-0.009 -9.60958,-3.8457 -9.60018,-9.86345 l 0.0193,-15.44867"
|
||||
id="path95" />
|
||||
<path
|
||||
style="fill:#ffcc00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
|
||||
d="m 336.40887,571.19801 c -22.1904,21.69206 -54.90246,32.28894 -85.96904,22.44429 -14.5366,-4.72957 -27.78502,-14.58361 -37.99638,-26.73193 l 12.59025,-22.63235 c 6.37504,10.47463 14.05706,20.09362 24.26842,27.10805 18.0438,12.34577 44.31499,17.75233 65.57453,8.45304 6.82638,-2.83022 13.38007,-6.187 19.39781,-10.85074 l 2.13441,2.20964"
|
||||
id="path99" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |