diff --git a/DECISIONS.md b/DECISIONS.md index 86eca794..35bf258a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -348,4 +348,8 @@ One line per decision: `[YYYY-MM-DD] : `. 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. \ No newline at end of file +[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). diff --git a/app/(dashboard)/bookkeeping/loading.tsx b/app/(dashboard)/bookkeeping/loading.tsx index 9f3b1fca..20d85454 100644 --- a/app/(dashboard)/bookkeeping/loading.tsx +++ b/app/(dashboard)/bookkeeping/loading.tsx @@ -3,21 +3,28 @@ import { Skeleton } from '@/components/ui/skeleton' export default function BookkeepingLoading() { return (
+ {/* Title (24px) + single "Nytt verifikat" split button (pill) */}
- -
- - -
+ +
-
+ + {/* Borderless table (JournalEntryList): header row + single-line rows */} +
+
+ + +
{[1, 2, 3, 4, 5, 6].map((i) => ( -
-
- - +
+ +
+ +
-
))}
diff --git a/app/(dashboard)/bookkeeping/periodiseringar/page.tsx b/app/(dashboard)/bookkeeping/periodiseringar/page.tsx index 3caf110d..4ca38264 100644 --- a/app/(dashboard)/bookkeeping/periodiseringar/page.tsx +++ b/app/(dashboard)/bookkeeping/periodiseringar/page.tsx @@ -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 = { + active: 'Aktiv', + completed: 'Avslutad', + cancelled: 'Makulerad', } function monthLabel(periodMonth: string): string { @@ -70,11 +61,12 @@ export default function AccrualSchedulesPage() { const [statusFilter, setStatusFilter] = useState('active') const [schedules, setSchedules] = useState([]) const [dueCount, setDueCount] = useState(0) + const [activeCount, setActiveCount] = useState(null) const [isLoading, setIsLoading] = useState(true) const [expanded, setExpanded] = useState>(new Set()) const [isPosting, setIsPosting] = useState(false) + const [postConfirmOpen, setPostConfirmOpen] = useState(false) const [dissolveTarget, setDissolveTarget] = useState(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 (
- + +

+ 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. +

+ + } + /> - {(dueCount > 0 || blockedInstallments > 0) && ( -
0 ? ( + { + if (!isPosting) setPostConfirmOpen(true) + }, + } + : undefined + } > -
- -
-

- {dueCount > 0 - ? `${dueCount} ${dueCount === 1 ? 'månad väntar' : 'månader väntar'} på att bokföras` - : 'Periodiseringar med fel'} -

-

- {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.'} -

-
-
- {canWrite && dueCount > 0 && ( - - )} -
- )} + {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. + + ) : blockedInstallments > 0 ? ( + + {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.`} + + ) : null} - setStatusFilter(v as StatusFilter)}> - - Aktiva - Avslutade - Alla - - + {/* Toolbar: status seg (concept scene 33) */} +
+ {( + [ + { key: 'active', label: 'Aktiva', count: activeCount }, + { key: 'completed', label: 'Avslutade', count: null }, + { key: 'all', label: 'Alla', count: null }, + ] as const + ).map(({ key, label, count }) => ( + + ))} +
{isLoading ? ( - - - - - - - +
+ {[1, 2, 3].map((i) => ( + + ))} +
) : schedules.length === 0 ? ( ) : ( - - - - - - - Beskrivning - Konto - Period - Totalt - Kvar - Status - - - - + <> +
+
+ + + + + + + + + + + {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 ( - toggleExpanded(schedule.id)} > - - - + + + + + + + + {isOpen && ( - - -
-
BeskrivningKontoPeriodTotaltKvarStatus +
{schedule.description || '-'} @@ -294,118 +300,148 @@ export default function AccrualSchedulesPage() { {schedule.supplier_invoice_id ? 'Leverantörsfaktura' : 'Kundfaktura'} )} - - + {schedule.balance_account} → {schedule.target_account} - - + {formatDate(schedule.period_start)} till {formatDate(schedule.period_end)} - - + {formatCurrency(schedule.total_amount)} - - + {formatCurrency(remaining)} - - - {badge.label} - - + + {schedule.status === 'cancelled' ? ( + + {SCHEDULE_STATUS_TEXT.cancelled} + + ) : ( + + {SCHEDULE_STATUS_TEXT[schedule.status]} + + )} + {canWrite && schedule.status === 'active' && remaining > 0 && ( - + )} - - +
- - - - - - - - - - {schedule.installments.map((installment) => ( - - - - - + + + {schedule.installments.map((installment) => ( + + + + + + + ))} + +
MånadBeloppStatusVerifikat
- {monthLabel(installment.period_month)} - - {formatCurrency(installment.amount)} - - {installment.status === 'posted' ? ( - Bokförd - ) : installment.status === 'cancelled' ? ( - Makulerad - ) : installment.last_error ? ( - - Fel - - {installment.last_error} - - - ) : ( - Väntar - )} - - {installment.journal_entry_id ? ( - - Öppna verifikat - - ) : ( - - - )} -
+ +
+ + + + + + + - ))} - -
MånadBeloppStatusVerifikat
-
- - + +
+ {monthLabel(installment.period_month)} + + {formatCurrency(installment.amount)} + + {installment.status === 'posted' ? ( + Bokförd + ) : installment.status === 'cancelled' ? ( + Makulerad + ) : installment.last_error ? ( + + Fel + + {installment.last_error} + + + ) : ( + Väntar + )} + + {installment.journal_entry_id ? ( + + Öppna verifikat + + ) : ( + - + )} +
+
+ + + )} ) })} - - - - + + +
+ +

+ Månadens andel bokförs automatiskt den sista dagen i varje månad. Öppna en rad + för att se varje månads verifikat. +

+ )} - {dissolveTarget && ( - !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" - > + + + !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 && (

{dissolveTarget.description || 'Periodisering'}

@@ -413,8 +449,8 @@ export default function AccrualSchedulesPage() { {formatDate(dissolveTarget.period_start)} till {formatDate(dissolveTarget.period_end)}

-
- )} + )} +
) } diff --git a/app/(dashboard)/customers/loading.tsx b/app/(dashboard)/customers/loading.tsx index 6ee6fc09..fb0ef4c3 100644 --- a/app/(dashboard)/customers/loading.tsx +++ b/app/(dashboard)/customers/loading.tsx @@ -3,6 +3,7 @@ import { Skeleton } from '@/components/ui/skeleton' export default function CustomersLoading() { return (
+ {/* Title (24px) + export menu + "Ny kund" action (pills) */}
@@ -10,18 +11,25 @@ export default function CustomersLoading() {
- -
+ + {/* Search toolbar */} + + + {/* Borderless table: header row + single-line rows */} +
+
+ + +
{[1, 2, 3, 4, 5, 6].map((i) => (
- - - + +
))} diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx index c7d6aadd..0de78744 100644 --- a/app/(dashboard)/deadlines/page.tsx +++ b/app/(dashboard)/deadlines/page.tsx @@ -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([]) 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(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, + ) => { + if (editingDeadline) { + await handleDeadlineEdit({ ...editingDeadline, ...data }) + } else { + await handleDeadlineCreate(data) + } + setShowForm(false) + setEditingDeadline(null) + } + + const openEdit = (deadline: Deadline) => { + setEditingDeadline(deadline) + setShowForm(true) + } + + const pageHeader = ( + +

{t('help_text')}

+

+ + {t('generate_open_settings')} + +

+ + } + action={ + + } + /> + ) + if (isLoading) { return (
- + {pageHeader}
{[1, 2, 3, 4].map((i) => ( -
-
-
- - -
- -
- - -
- +
+ +
+ +
))} @@ -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 (
- + {pageHeader} - {!hasSystemDeadlines && ( -
-
- -

- {t('no_system_deadlines_title')} - - {t('no_system_deadlines_description')} - -

-
-
- - - {t('generate_open_settings')} - - -
-
- )} - - {/* Overdue invoices alert */} - {overdueInvoices.count > 0 && ( - -
-
- -

- {t('overdue_invoices', { count: overdueInvoices.count })} - - {formatCurrency(overdueInvoices.total)} - -

-
- -
- - )} + {!hasSystemDeadlines ? ( + { + if (!isGenerating) void handleGenerateSystemDeadlines() + }, + }} + > + {t('no_system_deadlines_title')} {t('no_system_deadlines_description')} + + ) : overdueInvoices.count > 0 ? ( + + {t('overdue_invoices', { count: overdueInvoices.count })} ·{' '} + {formatCurrency(overdueInvoices.total)}. + + ) : null} + + { + 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} />
) diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 723fada7..b93a749b 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -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(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(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 (
+

{t('help_text')}

+ + } /> {mode === null && ( <> - {isSandbox && ( -
- -

- {t('sandbox_disabled')} -

+ {isSandbox && {t('sandbox_disabled')}} + + {/* Importera / Exportera as separate tabs (house seg), like before */} +
+ {( + [ + { key: 'import', label: t('tab_import') }, + { key: 'export', label: t('tab_export') }, + ] as const + ).map(({ key, label }) => ( + + ))} +
+ + {view === 'import' ? ( +
+
+ {hasBankingExtension && ( + {t('psd2_recommended')} + ) : ( + + {t('psd2_requires_subscription')} + + ) + } + chips={} + disabled={isSandbox} + onClick={() => setMode('psd2')} + /> + )} + {hasMigrationExtension && ( + + + + + + + + } + disabled={isSandbox} + onClick={() => setMode('migration')} + /> + )} + setMode('bank')} + /> + setMode('csv_data')} + /> + setMode('sie')} + /> +
+

{t('pgnote')}

+
+ ) : ( +
+
+ setSieDialogOpen(true)} + /> + {hasCloudBackup && ( + setCloudOpen((v) => !v)} + /> + )} +
+ {hasCloudBackup && cloudOpen && ( +
+ +
+ )}
)} - - - {t('tab_import')} - {t('tab_export')} - - - -
- {/* 1. Koppla bank */} - {hasBankingExtension && ( -
{ if (!isSandbox) setMode('psd2') }} - onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('psd2') } }} - > -
- -
-
-
-

{t('psd2_title')}

- {hasBankSync ? ( - {t('psd2_recommended')} - ) : ( - - {t('psd2_requires_subscription')} - - )} -
-

- {t('psd2_description')} -

-
- -
- )} - - {/* 2. Hämta från annat system */} - {hasMigrationExtension === true && ( -
{ if (!isSandbox) setMode('migration') }} - onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('migration') } }} - > -
-
- -
-
-

{t('migration_title')}

-

- {t('migration_description')} -

-
- -
-
- {([ - { 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 => ( -
- {provider.name} - {provider.name} -
- ))} -
-
- )} - - {/* 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. */} -
setMode('bank')} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('bank') } }} - > -
- -
-
-

{t('bankfile_title')}

-

- {t('bankfile_description')} -

-
- {['CSV', 'OFX', 'SEB', 'Swedbank', 'Nordea'].map(fmt => ( - - {fmt} - - ))} -
-
- -
- - {/* 4. CSV/Excel-data (ingående balanser, kunder, leverantörer) */} -
setMode('csv_data')} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('csv_data') } }} - > -
- -
-
-

{t('csv_data_title')}

-

- {t('csv_data_description')} -

-
- {[ - { 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 => ( - - {chip.label} - - ))} -
-
- -
- - {/* 5. Bokföringsdata (SIE) */} -
setMode('sie')} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('sie') } }} - > -
- -
-
-

{t('sie_title')}

-

- {t('sie_description')} -

-
- {['SIE4', '.se'].map(fmt => ( - - {fmt} - - ))} -
-
- -
-
-
- - + {/* SIE export as a small centered dialog (concept overlay convention) */} + + + + + {t('export_sie_title')} + + + {t('export_sie_dialog_description')} + +
- {/* SIE-export */} -
-
- {/* Identity */} -
-
- -
-
-

{t('export_sie_title')}

-

- {t('export_sie_description')} -

-
-
- - {/* Controls */} -
- - - -
-
-
- - {/* Molnsynkronisering (Google Drive) */} - {hasCloudBackup && ( -
- -
- )} + +
-
-
+ + + + + )} @@ -2328,3 +2238,81 @@ export default function ImportPage() {
) } + +// 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 ( + + ) +} + +// 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 ( + + + {name} + + ) +} diff --git a/app/(dashboard)/invoices/loading.tsx b/app/(dashboard)/invoices/loading.tsx index 79f60d2a..a4da46ca 100644 --- a/app/(dashboard)/invoices/loading.tsx +++ b/app/(dashboard)/invoices/loading.tsx @@ -3,25 +3,34 @@ import { Skeleton } from '@/components/ui/skeleton' export default function InvoicesLoading() { return (
+ {/* Title (24px) + primary action (single "Ny faktura" split button, pill) */}
- -
- - -
+ +
- - -
- {[1, 2, 3, 4, 5].map((i) => ( -
-
- - -
-
- - + + {/* Toolbar: status picker chip + search + fiscal-year picker */} +
+ + + +
+ + {/* Borderless table: header row over hairline-separated single-line rows */} +
+
+ + +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ +
+ +
))} diff --git a/app/(dashboard)/loading.tsx b/app/(dashboard)/loading.tsx index 4fa65c48..dbfc9201 100644 --- a/app/(dashboard)/loading.tsx +++ b/app/(dashboard)/loading.tsx @@ -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 (
- {/* Metrics row */} -
- {[1, 2, 3, 4].map((i) => ( -
- - - -
- ))} -
+ {/* Greeting hero (title + date line) */} +
+ + +
- {/* Income / Expenses */} -
- {[1, 2].map((i) => ( -
- - - -
- ))} -
- - {/* Alerts + deadlines */} -
-
- - - + {/* Single pane: header over a hairline, then list rows */} +
+
+ +
-
- - {[1, 2, 3].map((i) => ( -
- - + + +
+ {['w-44', 'w-52', 'w-40', 'w-48', 'w-44'].map((w, i) => ( +
+ + +
))}
-
+
) } diff --git a/app/(dashboard)/pending/loading.tsx b/app/(dashboard)/pending/loading.tsx index 8a9e2444..321fc6f5 100644 --- a/app/(dashboard)/pending/loading.tsx +++ b/app/(dashboard)/pending/loading.tsx @@ -3,17 +3,26 @@ import { Skeleton } from '@/components/ui/skeleton' export default function PendingLoading() { return (
+ {/* Title (24px) + conditional "Godkänn alla" action (pill) */}
-
+ + {/* Toolbar: segmented tabs + source picker */} +
+ + +
+ + {/* Per-operation cards with approve/reject actions */} +
{[1, 2, 3, 4].map((i) => (
- +
diff --git a/app/(dashboard)/reports/loading.tsx b/app/(dashboard)/reports/loading.tsx index d9312c8b..08c30dc6 100644 --- a/app/(dashboard)/reports/loading.tsx +++ b/app/(dashboard)/reports/loading.tsx @@ -3,10 +3,12 @@ import { Skeleton } from '@/components/ui/skeleton' export default function ReportsLoading() { return (
+ {/* Title (24px) + fiscal-year picker chip */}
- - + +
+ {/* Section heading (e.g. "Senaste rapporter") */}
{[1, 2, 3, 4, 5, 6].map((i) => ( diff --git a/app/(dashboard)/salary/loading.tsx b/app/(dashboard)/salary/loading.tsx index 4d5049e2..c0392003 100644 --- a/app/(dashboard)/salary/loading.tsx +++ b/app/(dashboard)/salary/loading.tsx @@ -3,17 +3,29 @@ import { Skeleton } from '@/components/ui/skeleton' export default function SalaryLoading() { return (
+ {/* Title (24px) + single "Starta körning" action (pill) */}
- -
- - -
+ +
- -
- {[1, 2, 3].map((i) => ( - + + {/* Borderless table of salary runs: header row + single-line rows */} +
+
+ + +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ +
+ + +
+
))}
diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx index ed1f6c88..1b280943 100644 --- a/app/(dashboard)/skattekonto/page.tsx +++ b/app/(dashboard)/skattekonto/page.tsx @@ -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(null) const [tx, setTx] = useState(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 = ( + +

{t('help_text')}

+
+ ) + if (notConnected) { return ( -
- - - - - - - - +
+ + + +
) } if (loadError) { return ( -
- - - - - - - - +
+ + + +
) } + const data = saldo?.data ?? null + return ( -
- + - @@ -354,72 +384,131 @@ export default function SkattekontoPage() { } /> - {reconnectMessage && ( -
-
- -

{reconnectMessage}

+ {/* Saldo as compact stat tiles (house metric-card idiom, KPIHeroCards) */} +
+ {loading && !data ? ( +
+ +
- -
- )} + ) : !data ? ( +

+ Inget saldo hämtat ännu: klicka på ”Synkronisera nu”. +

+ ) : ( + <> +
+
+
+ +

Saldo hos Skatteverket

+
+

+ {formatCurrency(data.saldoSkatteverket)} +

+

+ OCR {data.ocrNummer}{' '} + + {saldo?.lastSyncedAt && ( + <> + {' '}· synkad{' '} + {formatDateTime(saldo.lastSyncedAt)} + + )} +

+ {data.rantaSkatteverket !== 0 && ( +

+ Preliminär ränta: {formatCurrency(data.rantaSkatteverket)} +

+ )} + {data.saldoKronofogden !== 0 && ( +

+ Hos Kronofogden: {formatCurrency(data.saldoKronofogden)} + {data.rantaKronofogden !== 0 && + ` (ränta ${formatCurrency(data.rantaKronofogden)})`} +

+ )} +
- +
+

Nästa dragning

+ {nextCharge ? ( + <> +

+ {formatCurrency(nextCharge.amount)} +

+

+ {formatDateLong(nextCharge.due)} + {nextCharge.count > 1 && ` · ${nextCharge.count} händelser`} +

+ + ) : ( +

+ Inga kommande dragningar. +

+ )} +
+
- - - Transaktioner - - - - - - Genomförda {tx?.booked ? `(${tx.booked.length})` : ''} - - - Förfallna {tx?.overdue ? `(${tx.overdue.length})` : ''} - - - Kommande {tx?.upcoming ? `(${tx.upcoming.length})` : ''} - - - - - - - - - - - - - - + {reconnectMessage ? ( + + {reconnectMessage} + + ) : shortfall !== null && nextCharge ? ( + setShowPayment(true) }} + > + {t('attn_shortfall', { + date: formatDateLong(nextCharge.due), + charge: formatCurrency(nextCharge.amount), + missing: formatCurrency(shortfall), + })} + + ) : null} + + {data.informationstext.length > 0 && ( +
+

+ Information från Skatteverket +

+ {data.informationstext.map((info, i) => ( +

+ {info} +

+ ))} +
+ )} + + )} + + + {/* One dry table with band rows (concept): Kommande, Förfallna, Genomförda */} + + +

+ {t('pgnote', { amount: formatCurrency(data?.saldoKronofogden ?? 0) })} +

+ + + + + + {t('payment_title')} + + {nextCharge && ( + + {t('payment_description', { + date: formatDateLong(nextCharge.due), + charge: formatCurrency(nextCharge.amount), + })} + + )} + +
+
+
{t('payment_bankgiro_label')}
+
5050-1055
+
+
+
OCR
+
+ {data?.ocrNummer} + {data && ( + + )} +
+
+ {shortfall !== null && ( +
+
{t('payment_shortfall_label')}
+
{formatCurrency(shortfall)}
+
+ )} +
+
+
) } -function PageHeading({ right }: { right?: React.ReactNode }) { - return +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 ( - - - Hämtar saldo… - - - ) - } - - if (!saldo?.data) { - return ( - - - Inget saldo hämtat ännu: klicka på "Synkronisera nu". - - - ) - } - - const { data } = saldo - const skvNegative = data.saldoSkatteverket < 0 - const kfmNegative = data.saldoKronofogden < 0 - - return ( - - -
-
-

- Skatteverket -

-

- {formatCurrency(data.saldoSkatteverket)} -

- {data.rantaSkatteverket !== 0 && ( -

- Preliminär ränta: {formatCurrency(data.rantaSkatteverket)} -

- )} -
-
-

- Kronofogden -

-

- {formatCurrency(data.saldoKronofogden)} -

- {data.rantaKronofogden !== 0 && ( -

- Preliminär ränta: {formatCurrency(data.rantaKronofogden)} -

- )} -
-
- -
-
-

- OCR -

-

- {data.ocrNummer} - -

-
-
-

- Nästa avstämning -

-

{data.nastaAvstamningsdatum}

-
-
-

- Saldo per -

-

- {formatDateLong(data.senastUppdaterad)} -

-
-
- - {saldo.lastSyncedAt && ( -

- Synkas automatiskt varje natt. Senast synkad{' '} - - {formatDateTime(saldo.lastSyncedAt)} - - . Skatteverket uppdaterar saldot periodvis: datumet ovan ändras - inte varje gång du synkroniserar. -

- )} - - {data.informationstext.length > 0 && ( -
-

- Information från Skatteverket -

-
    - {data.informationstext.map((t, i) => ( -
  • {t}
  • - ))} -
-
- )} -
-
- ) -} - -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

{emptyText}

+ 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 ( +

+ {t('no_transactions')} +

+ ) } return ( - - - - Datum - {showForfallodatum && Förfallodatum} - Beskrivning - Belopp - Status - - - - - {rows.map(row => { - const negative = Number(row.belopp_skatteverket) < 0 - const isBooked = !!row.journal_entry_id - return ( - - {formatDate(row.transaktionsdatum)} - {showForfallodatum && ( - - {row.forfallodatum ? formatDate(row.forfallodatum) : '-'} - - )} - - {row.transaktionstext} - {!isBooked && row.match_suggestion && ( -

- Möjlig dublett av{' '} - {row.match_suggestion.voucher_series && row.match_suggestion.voucher_number +

+
+ + + + + + + + + {sections.map((section) => ( + + + + + {section.rows.map((row) => ( + + ))} + + ))} + +
DatumHändelseBelopp +
+ {section.label} +
+
+ ) +} + +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 ( + + + {formatDate(displayDate)} + + + + {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 ? ( + + {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)}) -

- )} - - - {formatCurrency(Number(row.belopp_skatteverket))} - - - {isBooked ? ( - - - Bokförd - - ) : row.match_suggestion ? ( - Möjlig dublett - ) : ( - Ej bokförd - )} - - - {isBooked ? ( - - ) : ( -
- - -
- )} -
- - ) - })} - - + : t('chip_draft'), + })} +
+ ) : ( + + {t('chip_not_booked')} + + ) + )} +
+ + 0 && 'text-success', + )} + > + {amount > 0 ? `+${formatCurrency(amount)}` : formatCurrency(amount)} + + + {isBooked ? ( + + {t('action_show_voucher')} + + ) : ( + + + + + )} + + ) } diff --git a/app/(dashboard)/supplier-invoices/loading.tsx b/app/(dashboard)/supplier-invoices/loading.tsx index 6bfed8e3..9bcce884 100644 --- a/app/(dashboard)/supplier-invoices/loading.tsx +++ b/app/(dashboard)/supplier-invoices/loading.tsx @@ -3,27 +3,34 @@ import { Skeleton } from '@/components/ui/skeleton' export default function SupplierInvoicesLoading() { return (
+ {/* Title (24px) + single "Registrera" action (pill) */}
-
- + + {/* Toolbar: status picker chip + search + fiscal-year picker */} +
+ +
-
+ + {/* Borderless table: header row + single-line rows */} +
+
+ + +
{[1, 2, 3, 4, 5, 6].map((i) => (
-
- - -
-
+ +
- +
))} diff --git a/app/(dashboard)/transactions/loading.tsx b/app/(dashboard)/transactions/loading.tsx index 4bd4c73e..82ab2e5a 100644 --- a/app/(dashboard)/transactions/loading.tsx +++ b/app/(dashboard)/transactions/loading.tsx @@ -3,23 +3,35 @@ import { Skeleton } from '@/components/ui/skeleton' export default function TransactionsLoading() { return (
+ {/* Status bar: title (24px) + single "Importera" split button (pill) */}
- - + +
-
- - + + {/* Toolbar: inbox/history segmented tabs + search + source picker */} +
+ + +
- -
+ + {/* Borderless table: header row + single-line transaction rows */} +
+
+ + +
{[1, 2, 3, 4, 5, 6].map((i) => ( -
-
- - +
+ +
+ +
-
))}
diff --git a/components/deadlines/DeadlineCard.tsx b/components/deadlines/DeadlineCard.tsx deleted file mode 100644 index fa3e65f0..00000000 --- a/components/deadlines/DeadlineCard.tsx +++ /dev/null @@ -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(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 ( -
- - {dayNum} {monthStr} - - -

- {deadline.title} -

- - {!completed && relative && ( - - {relative.text} - - )} - - {completed && ( - - )} -
- ) - } - - // -- Full card -- - return ( -
-
- {/* Main row */} -
- {/* Date block */} -
- - {dayNum} - - - {monthStr} - -
- - {/* Divider */} -
- - {/* Content */} -
-
-

- {deadline.title} -

- {deadline.deadline_type !== 'other' && ( - - {DEADLINE_TYPE_LABELS[deadline.deadline_type]} - - )} -
- -
- {deadline.due_time && ( - - kl {deadline.due_time.slice(0, 5)} - - )} - {deadline.due_time && deadline.customer && ( - · - )} - {deadline.customer && ( - - {deadline.customer.name} - - )} -
-
- - {/* Right: relative date + action */} -
- {!completed && relative && ( - - )} - - {completed ? ( - - - Klar - - ) : ( - - )} - - {/* Edit hint */} - {onEdit && !confirming && ( - - )} -
-
- - {/* Inline confirmation bar */} -
-
-
-

- Markera {deadline.title} som klar? -

-
- - -
-
-
-
-
-
- ) -} diff --git a/components/deadlines/DeadlineFilters.tsx b/components/deadlines/DeadlineFilters.tsx deleted file mode 100644 index 4a283d3b..00000000 --- a/components/deadlines/DeadlineFilters.tsx +++ /dev/null @@ -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 ( -
- - - - - {hasFilters && ( - - )} -
- ) -} diff --git a/components/deadlines/DeadlineGroupCard.tsx b/components/deadlines/DeadlineGroupCard.tsx index 5e59fdf7..ab3ef864 100644 --- a/components/deadlines/DeadlineGroupCard.tsx +++ b/components/deadlines/DeadlineGroupCard.tsx @@ -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(null) - const confirmRef = useRef(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 ( -
-
-
- {/* Shared date block */} -
- - {dayNum} - - - {monthStr} - -
+
+ {/* Shared skattekonto date: always Skatteverket's mark (white ground, + badge not chrome). */} + + Skatteverket + -
- - {/* Group content */} -
-
-

{t('group_skattekonto_title')}

- - {t('group_same_day', { count: deadlines.length })} - -
- -
- {deadlines.map((deadline) => ( -
{ - 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', - )} - > -

{deadline.title}

- - {onEdit && !confirmingId && ( - - )} -
- ))} -
-
-
- - {/* Inline confirmation bar (one at a time, mirrors DeadlineCard) */} -
+

+ + {deadlineDateLabel(first.due_date)} + + · + {t('group_skattekonto_title')} +

+

-

- {confirming && ( -
-

- {t.rich('group_confirm_question', { - title: confirming.title, - hl: (chunks) => ( - {chunks} - ), - })} -

-
- - -
-
- )} -
+ {t('group_same_day', { count: deadlines.length })} +

+ +
+ {deadlines.map((deadline) => ( +
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', + )} + > +

{deadline.title}

+ + {onEdit && ( +
+ ))}
diff --git a/components/deadlines/DeadlineList.tsx b/components/deadlines/DeadlineList.tsx index 3a91a960..57a84dcd 100644 --- a/components/deadlines/DeadlineList.tsx +++ b/components/deadlines/DeadlineList.tsx @@ -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, ReadonlyArray> = { + tax: ['tax'], + invoicing: ['invoicing'], + own: ['delivery', 'report', 'other'], +} interface DeadlineListProps { deadlines: Deadline[] - customers: { id: string; name: string }[] - onDeadlineCreate: (data: Omit) => Promise onDeadlineToggle: (deadline: Deadline) => Promise - onDeadlineEdit: (deadline: Deadline) => Promise - onDeadlineDelete: (deadline: Deadline) => Promise + 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('all') - const [showForm, setShowForm] = useState(false) - const [editingDeadline, setEditingDeadline] = useState(null) + const t = useTranslations('deadlines') + const [segment, setSegment] = useState('all') + const [confirmTarget, setConfirmTarget] = useState(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) => { - 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 (
- {/* Toolbar */} -
- - + {/* Toolbar: the type seg (concept: Alla / Skatt / Fakturering / Egna) */} +
+ {( + [ + { 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 }) => ( + + ))}
- {/* Content */} - {filteredDeadlines.length === 0 ? ( -
-

- {statusFilter !== 'all' || typeFilter !== 'all' - ? 'Inga deadlines matchar filtret.' - : 'Inga deadlines ännu.'} +

+ {/* The thread */} + {filteredDeadlines.length === 0 ? ( +

+ {segment !== 'all' ? t('empty_filtered') : t('empty_title')}

- -
- ) : ( -
- {sections.map(({ key, label, items }) => ( -
-
-

- {label} -

- - {items.length} - -
-
-
- {toEntries(items).map((entry) => - entry.kind === 'group' ? ( - - ) : ( - - ), - )} -
-
- ))} -
- )} + ) : ( +
+ {sections.map(({ key, label, items }) => ( +
+
+

+ {label} +

+ + {items.length} + +
+
+
+ {toEntries(items).map((entry) => + entry.kind === 'group' ? ( + + ) : ( + + ), + )} +
+
+ ))} +
+ )} - { - if (deadline.id) { - const full = deadlines.find(d => d.id === deadline.id) - if (full) onDeadlineDelete(full) - } + {/* "Närmast" pane (concept aside) */} + +
+ + !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} />
) diff --git a/components/deadlines/DeadlineRow.tsx b/components/deadlines/DeadlineRow.tsx new file mode 100644 index 00000000..f38f2c2f --- /dev/null +++ b/components/deadlines/DeadlineRow.tsx @@ -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 = { + 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 ( + + + ) + } + if (logo) { + // Deliberately white in both themes: the authority marks are badges, not + // chrome, and their brand colors need a white ground. + return ( + + {logo.alt} + + ) + } + const Icon = TYPE_ICONS[deadline.deadline_type] ?? CalendarClock + return ( + + + ) +} + +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 ( +
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', + )} + > + + +
+

+ + {deadlineDateLabel(deadline.due_date)} + + · + {deadline.title} +

+ {subParts.length > 0 && ( +

+ {subParts.join(' · ')} +

+ )} +
+ +
+ {completed ? ( + + ) : ( + + )} + {onEdit && ( +
+
+ ) +} diff --git a/components/deadlines/index.ts b/components/deadlines/index.ts index 3118eff9..7bfe3969 100644 --- a/components/deadlines/index.ts +++ b/components/deadlines/index.ts @@ -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' diff --git a/messages/en.json b/messages/en.json index 8622a425..087ab817 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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 {title} 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}." } } diff --git a/messages/sv.json b/messages/sv.json index 20fc5162..25792d04 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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 {title} 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}." } } diff --git a/public/logos/anthropic.png b/public/logos/anthropic.png new file mode 100644 index 00000000..ae3943b0 Binary files /dev/null and b/public/logos/anthropic.png differ diff --git a/public/logos/bolagsverket.jpg b/public/logos/bolagsverket.jpg new file mode 100644 index 00000000..7f93117d Binary files /dev/null and b/public/logos/bolagsverket.jpg differ diff --git a/public/logos/claude-ai-icon.webp b/public/logos/claude-ai-icon.webp new file mode 100644 index 00000000..4e888a5b Binary files /dev/null and b/public/logos/claude-ai-icon.webp differ diff --git a/public/logos/enable-banking-icon.png b/public/logos/enable-banking-icon.png new file mode 100644 index 00000000..64badb4a Binary files /dev/null and b/public/logos/enable-banking-icon.png differ diff --git a/public/logos/enable-banking.webp b/public/logos/enable-banking.webp new file mode 100644 index 00000000..088df9c3 Binary files /dev/null and b/public/logos/enable-banking.webp differ diff --git a/public/logos/skatteverket.svg b/public/logos/skatteverket.svg new file mode 100644 index 00000000..a8b33fa6 --- /dev/null +++ b/public/logos/skatteverket.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/logos/skatteverket_color.svg b/public/logos/skatteverket_color.svg new file mode 100644 index 00000000..94abb074 --- /dev/null +++ b/public/logos/skatteverket_color.svg @@ -0,0 +1,142 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + +