1ccf7ab81f
* feat(transactions): concept shell for scene 10 (header, toolbar, footer)
- Header: title + Importera SplitButton (import guide / manual entry,
last-used mode persisted via ui_state.create_mode.transactions),
replacing the three-button row
- Toolbar in concept order: [Att bokföra/Alla seg with count chip]
[search] [Välj flera as quiet toggle] ... [source ContextPicker far
right merging the old in-list source dropdown (bank/Skatteverket)]
- Bank sync status + sync-now + Bankavstämning move to the concept's
footer status line together with the honest visible-row counter
("n att hantera")
- skv-reconnect banner and all dialogs/actions untouched
Row cards -> concept dry-table conversion follows separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): concept row language for the inbox cards
Rows read like the scene 10 table: date as the first tabular column,
description on one line, amount right (+income in sage), auto-detected
invoice matches as beige suggestion chips instead of primary pills,
tighter hairline rows at 13px. Every action unchanged: Bokför pill,
match/assistant/overflow menu, batch checkboxes, exit animations,
extraction status.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(transactions): de-bloat rows and chrome to match the concept
- Rows carry only the essentials: date, description, amount, Bokför and
one overflow menu. The inline match and ask-assistant icon buttons
fold into the overflow menu (all actions still one click away)
- The "n nya banktransaktioner sen ditt senaste besök" banner is removed;
the footer sync line covers it
- The Skatteverket reconnect banner becomes the one-ochre-sentence
AttnLine with an inline action (convention 6)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(ui): extract dry-table primitives into components/ui/dry-table
The concept table styles (TH/TD/VTH/VTD classes, quiet links, RowFoldout)
move out of JournalEntryList so the transaction inbox can render the exact
same table language.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): Synka bank nu in the Importera menu
Extracts the per-connection sync/reconnect logic into a shared useBankSync()
hook (BankSyncNowButton keeps using it) and adds the concept's first menu row
to the Importera split button: sync all active connections, with the
last-synced age as the row description. Hidden without a connection or the
bank_sync capability; the footer button stays the gated conversion surface.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): per-account source picker with balances
The context chip becomes the concept's account chooser: always visible in
inbox mode, one row per enabled cash account (PSD2 balance annotation),
an Övriga bucket for bank rows without a registered account, and a
Skattekonto row annotated with the cached saldo. The trigger reads
'Alla källor · <sum>' where the sum covers SEK ledgers + skattekonto.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): concept dry-table for the inbox
The inbox list becomes the exact Bokforing table: borderless dry-table with
hover-revealed checkboxes and chevrons, one-line rows (date, description,
amount, quiet primary pill + overflow + foldout), and a RowFoldout expansion
carrying the row detail and the full action set as quiet links. Skattekonto
rows render as chip-marked table rows with inline actions. The 'Valj flera'
toggle and the floating batch bar are replaced by the concept bulkbar that
pops in above the table once a row is selected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(i18n): singular form for bulkbar selection count
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): suggestion-first Bokfor flow (concept scene 10)
Bokfor on a row with a suggestion now goes straight to the compact
kontering confirm (QuickReview) showing the proposed verifikat; the full
template picker becomes the fallback and the 'Byt mall' path. Rows carry
the concept's 'Forslag: <mall>' chip (with D/K in the tooltip and the
foldout meta). The picker dialog is de-bloated: alternate paths (manuellt,
matcha, ignorera) collapse into quiet links. Modal scroll containers
(DialogContent, SlideOverBody) keep a visible scrollbar thumb: with the
app-wide auto-hide, a long dialog read as cut off at the fold.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(transactions): drop suggestion UI and foldout action links
Founder direction: no booking suggestions in the list for now (logic comes
later), and the foldout duplicated the overflow menu: the three dots are
the clearer surface. The foldout keeps row detail only (FX conversion,
original bank name, 1930/1630 hint, extraction status) and rows without
any detail no longer expand. The Skattekonto picker row drops its saldo
annotation; the Alla kallor sum covers bank ledgers only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(transactions): dry-table for the Alla view + pill chips app-wide
The history list joins the inbox's table language: Datum, Beskrivning,
Belopp, Status columns with Bokford as muted text + Visa verifikat quiet
link (normal state) and Ej bokford as the beige exception chip with an
inline Bokfor pill; the business/private tabs become the house seg and the
KALLA header row becomes the standard chip-picker. Skattekonto rows get
the same treatment. SEK conversion moves to a tooltip (one-line rows).
Badge itself becomes the concept chip: pill radius (99px), 11.5px, quiet
padding, replacing the boxy rounded-md look the founder flagged on the
Underlag saknas badge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): audit round: viewer-disabled split options, touch fallback
Regression-audit fixes: SplitButton options support disabled+disabledTitle
so viewer-gated create paths render inert with the viewer tooltip instead
of silently no-opping (wired on Ny transaktion); the history list gets its
Kopplad till faktura indicator back; dead transactionsWithMatches memo
removed; and coarse-pointer devices now always show hover-revealed
controls (checkboxes, chevrons, quiet row actions) since touch has no
hover.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
166 lines
5.0 KiB
TypeScript
166 lines
5.0 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { useTranslations } from 'next-intl'
|
|
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import { onBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
|
|
import { useCompany } from '@/contexts/CompanyContext'
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/info-tooltip'
|
|
|
|
interface ConnectionRow {
|
|
id: string
|
|
status: string | null
|
|
last_synced_at: string | null
|
|
}
|
|
|
|
const STALE_THRESHOLD_MS = 36 * 60 * 60 * 1000
|
|
|
|
type ChipState =
|
|
| { kind: 'none' }
|
|
| { kind: 'attention'; count: number }
|
|
| { kind: 'stale'; mostRecent: string }
|
|
| { kind: 'healthy'; mostRecent: string | null }
|
|
|
|
export function getChipState(rows: ConnectionRow[], now: number = Date.now()): ChipState {
|
|
if (rows.length === 0) return { kind: 'none' }
|
|
|
|
const needsAttention = rows.filter(
|
|
(r) => r.status === 'expired' || r.status === 'error',
|
|
)
|
|
if (needsAttention.length > 0) {
|
|
return { kind: 'attention', count: needsAttention.length }
|
|
}
|
|
|
|
const mostRecent = rows
|
|
.map((r) => r.last_synced_at)
|
|
.filter((s): s is string => Boolean(s))
|
|
.sort()
|
|
.pop()
|
|
|
|
if (mostRecent && now - new Date(mostRecent).getTime() > STALE_THRESHOLD_MS) {
|
|
return { kind: 'stale', mostRecent }
|
|
}
|
|
|
|
return { kind: 'healthy', mostRecent: mostRecent ?? null }
|
|
}
|
|
|
|
export function useAgeFormatter() {
|
|
const t = useTranslations('transactions')
|
|
return (iso: string): string => {
|
|
const ms = Date.now() - new Date(iso).getTime()
|
|
const min = Math.floor(ms / 60000)
|
|
if (min < 1) return t('bank_sync_age_just_now')
|
|
if (min < 60) return t('bank_sync_age_minutes', { count: min })
|
|
const h = Math.floor(min / 60)
|
|
if (h < 24) return t('bank_sync_age_hours', { count: h })
|
|
const d = Math.floor(h / 24)
|
|
return t('bank_sync_age_days', { count: d })
|
|
}
|
|
}
|
|
|
|
export default function BankSyncStatusChip() {
|
|
const t = useTranslations('transactions')
|
|
const formatAge = useAgeFormatter()
|
|
const { company } = useCompany()
|
|
const [rows, setRows] = useState<ConnectionRow[] | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!company?.id) return
|
|
const companyId = company.id
|
|
let cancelled = false
|
|
const supabase = createClient()
|
|
|
|
const load = () => {
|
|
supabase
|
|
.from('bank_connections')
|
|
.select('id, status, last_synced_at')
|
|
.eq('company_id', companyId)
|
|
.then(({ data, error }) => {
|
|
if (cancelled) return
|
|
// On error keep the chip hidden (empty rows) rather than rendering a
|
|
// false "healthy" state from stale data.
|
|
setRows(error ? [] : (data ?? []))
|
|
})
|
|
}
|
|
|
|
load()
|
|
// Refetch when a manual "Sync now" / reconnect elsewhere on the page
|
|
// changes the connections, so the chip doesn't keep showing "synced 2d ago".
|
|
const unsubscribe = onBankSyncUpdated(load)
|
|
|
|
return () => {
|
|
cancelled = true
|
|
unsubscribe()
|
|
}
|
|
}, [company?.id])
|
|
|
|
if (!rows) return null
|
|
|
|
const state = getChipState(rows)
|
|
|
|
if (state.kind === 'none') return null
|
|
|
|
if (state.kind === 'attention') {
|
|
return (
|
|
<Link
|
|
href="/settings/banking"
|
|
className="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/5 px-2.5 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
|
|
>
|
|
<AlertTriangle className="h-3.5 w-3.5" />
|
|
<span>
|
|
{state.count === 1
|
|
? t('bank_sync_attention_one')
|
|
: t('bank_sync_attention_many', { count: state.count })}
|
|
</span>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
if (state.kind === 'stale') {
|
|
return (
|
|
<Link
|
|
href="/settings/banking"
|
|
className="inline-flex items-center gap-1.5 rounded-md border border-warning/40 bg-warning/5 px-2.5 py-1 text-xs text-warning transition-colors hover:bg-warning/10"
|
|
>
|
|
<AlertTriangle className="h-3.5 w-3.5" />
|
|
<span>
|
|
{t('bank_sync_stale_warning')}
|
|
<span className="ml-1 tabular-nums opacity-70">({formatAge(state.mostRecent)})</span>
|
|
</span>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
// healthy
|
|
return (
|
|
<TooltipProvider delayDuration={300}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex cursor-help items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground">
|
|
<RefreshCw className="h-3.5 w-3.5" />
|
|
<span>
|
|
{t('bank_sync_auto_nightly')}
|
|
{state.mostRecent && (
|
|
<>
|
|
{t('bank_sync_last_separator')}
|
|
<span className="tabular-nums">{formatAge(state.mostRecent)}</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="max-w-[320px]">
|
|
<div className="text-sm leading-relaxed">{t('bank_sync_latency_hint')}</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)
|
|
}
|