Design critique: normalize daily-operator flows + UX improvements (#741)
* style(design): normalize daily-operator flows to locked design system Sweep the dashboard, transactions, reconciliation, invoicing and supplier flows for design-system violations (.claude/rules/design.md): - font-medium removed from Hedvig display headings/numerals - font-mono -> tabular-nums on monetary values (voucher ids stay mono) - raw Tailwind status colors -> Badge variants / muted-alert pattern - semantic colors removed from chrome backgrounds (deadline widgets, icon halos) - hand-rolled skeletons/empty-states -> Skeleton / EmptyState primitives - opacity-suffixed borders, the invisible warning-foreground count color, and shadow-sm/rounded-xl on non-overlay surfaces normalized The four files that also received UX changes carry their token fixes in the following commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ux): clearer dashboard CTA, match confidence, AI provenance, invoice actions Four high-impact UX fixes from the design critique (these files also carry their design-system token normalization): - Dashboard: render the next-best-action hero for every agent-built company, not only 'slim' nav density, so there is always one obvious next step instead of four equal-weight metric tiles. - Reconciliation: surface the match engine's 0-1 confidence as a graded strength badge (Stark / Trolig / Svag traff) in the shared verifikat picker rows and selected chip; drop the uninformative binary "Foreslagen traff" badge from the match dialog. - Supplier inbox: show AI-filled provenance per extracted field (a success dot that clears once the user verifies/edits the value) so misparsed amounts/dates get proofread before they post to an immutable verifikat. - Invoice detail: keep each status's primary action only in the header row; the sidebar "Status actions" card now holds secondary/reversible actions only (makulera, ta bort, skapa kreditnota, manual-send alternative), removing the duplicated CTAs and closing a viewer-permission gap on the old sidebar buttons. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(ux): Tier-B medium design-critique fixes across daily-operator flows - Reconciliation: standardise the match-confirm verb on "Matcha" (was "Koppla" in MatchVoucherDialog) and replace the hand-rolled date <input>s in the bank reconciliation view with the Input primitive. - Supplier inbox: fold the two alternative bookings (Skapa leverantorsfaktura / Bokfor som verifikat) behind a single "Andra satt att bokfora" dropdown so the default path (Matcha mot transaktion) stays the lone primary action. - Duplicate-payment guard: demote the "Skapa ny verifikation anda" escape hatch to a ghost button so the safe "Koppla till befintlig" path dominates. - Onboarding: raise the "start fresh" escape hatch from a muted text link to a visible secondary button; normalise the checklist's off-scale spacing. - Supplier flows: finish the font-mono -> tabular-nums sweep on monetary values in the supplier-invoice detail / create / review surfaces (ids stay mono). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deadlines): keep overdue rows visually distinct (destructive chrome is allowed) The Tier-A normalization stripped all semantic-color row tints from the deadline widgets, but design.md exempts --destructive ("only --destructive survives in chrome"). Restore a subtle bg-destructive/5 on OVERDUE rows so missed tax/AGI deadlines (-> skattetillagg) stay noticeable in a list scan; action-needed (warning) rows stay clean since warning is data-only. Surfaced by the Swedish compliance review on #741. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reconciliation): hide match-strength badge on already-matched verifikat Per the Swedish compliance review on #741: a green "Stark traff" confidence badge rendered alongside "Redan matchad" could visually nudge an accidental double-match of a posted verifikat (a BFL 5 kap audit-trail concern). Suppress the strength badge when linked_transaction_count > 0 so "Redan matchad" is the lone signal there; N:1 matching stays an explicit opt-in. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
274fe0743f
commit
36e3f6ceb0
@@ -536,12 +536,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<h1 className={cn('font-display text-2xl sm:text-3xl font-medium tracking-tight', !invoice.invoice_number && !isSelfBilled && 'italic text-muted-foreground')}>{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : (invoice.invoice_number ?? '—')}</h1>
|
||||
<h1 className={cn('font-display text-2xl sm:text-3xl tracking-tight', !invoice.invoice_number && !isSelfBilled && 'italic text-muted-foreground')}>{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : (invoice.invoice_number ?? '—')}</h1>
|
||||
{isProforma && (
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">{t('badge_proforma')}</Badge>
|
||||
<Badge variant="outline">{t('badge_proforma')}</Badge>
|
||||
)}
|
||||
{isDeliveryNote && (
|
||||
<Badge variant="secondary" className="bg-success/10 text-success">{t('badge_delivery_note')}</Badge>
|
||||
<Badge variant="success">{t('badge_delivery_note')}</Badge>
|
||||
)}
|
||||
{isSelfBilled && (
|
||||
<Badge variant="outline">{t('badge_self_billed')}</Badge>
|
||||
@@ -1123,8 +1123,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="h-3 w-3 text-orange-600" />
|
||||
<span className="text-xs text-orange-600">{t('reminder_objection')}</span>
|
||||
<MessageSquare className="h-3 w-3 text-destructive" />
|
||||
<span className="text-xs text-destructive">{t('reminder_objection')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1190,9 +1190,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
|
||||
{/* Converted from proforma */}
|
||||
{convertedFromInvoice && (
|
||||
<Card className="border-blue-300">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-blue-600">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
{t('converted_card_title')}
|
||||
</CardTitle>
|
||||
@@ -1217,66 +1217,41 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
<CardHeader>
|
||||
<CardTitle>{t('actions_card_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
{/* Secondary actions only. The primary next-step for every status
|
||||
(convert / finalize / send / mark-paid) lives in the header
|
||||
action row next to the status badge — this card holds the
|
||||
reversible/destructive alternatives so there is one obvious
|
||||
next step, not two competing copies of it. */}
|
||||
<CardContent className="space-y-2">
|
||||
{isProforma && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={convertToInvoice}
|
||||
disabled={isConverting}
|
||||
>
|
||||
{isConverting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('convert_to_invoice')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => updateStatus('cancelled')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
{t('cancel_action')}
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => updateStatus('cancelled')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
{t('cancel_action')}
|
||||
</Button>
|
||||
)}
|
||||
{!isProforma && invoice.status === 'draft' && (
|
||||
isUnnumberedDraft ? (
|
||||
<>
|
||||
{/* Unnumbered draft (saved via "Spara som utkast"): review
|
||||
and create it, or remove it without a trace. */}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={openFinalizeDialog}
|
||||
disabled={isFinalizing}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
{t('finalize_action')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-destructive hover:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('remove_action')}
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-destructive hover:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('remove_action')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{!isDeliveryNote && customerHasEmail ? (
|
||||
{/* When the customer has an email the header offers "Send via
|
||||
email" as the primary; keep the manual-mark-sent path here
|
||||
as the secondary alternative (it is not in the header). */}
|
||||
{!isDeliveryNote && customerHasEmail && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => openSendDialog('email')}
|
||||
>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
{t('send_via_email')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-muted-foreground"
|
||||
@@ -1289,27 +1264,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
{t('send_manual_hint_with_email')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!isDeliveryNote && (
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-2 dark:bg-yellow-950/30 dark:border-yellow-800">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-700 dark:text-yellow-400">
|
||||
{t('no_customer_email_warning')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => openSendDialog('manual')}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{t('mark_sent_manually')}
|
||||
</Button>
|
||||
<p className="text-[11px] text-muted-foreground/60 px-1 -mt-1">
|
||||
{t('send_manual_hint_no_email')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1323,25 +1277,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => setShowPaymentDialog(true)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
{t('mark_as_paid')}
|
||||
</Button>
|
||||
<Link href={`/invoices/${invoice.id}/credit`} className="block">
|
||||
<Button variant="outline" className="w-full">
|
||||
<ReceiptText className="mr-2 h-4 w-4" />
|
||||
{t('create_credit_note')}
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{invoice.status === 'paid' && isRealInvoice && (
|
||||
{((invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'paid') && isRealInvoice) && (
|
||||
<Link href={`/invoices/${invoice.id}/credit`} className="block">
|
||||
<Button variant="outline" className="w-full">
|
||||
<ReceiptText className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
DataList,
|
||||
DataListRow,
|
||||
@@ -244,12 +245,12 @@ export default function InvoicesPage() {
|
||||
<DataList>
|
||||
{isLoading ? (
|
||||
[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 animate-pulse">
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-32 rounded bg-muted" />
|
||||
<div className="h-3 w-48 rounded bg-muted" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<div className="h-5 w-24 rounded bg-muted" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</div>
|
||||
))
|
||||
) : filteredInvoices.length === 0 ? (
|
||||
|
||||
@@ -404,7 +404,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Card className="animate-pulse"><CardContent className="h-48" /></Card>
|
||||
<Card><CardContent className="p-6"><Skeleton className="h-48 w-full" /></CardContent></Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -442,7 +442,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<h1 className="font-display text-2xl sm:text-3xl font-medium tracking-tight">
|
||||
<h1 className="font-display text-2xl sm:text-3xl tracking-tight">
|
||||
{t('arrival_header', { number: invoice.arrival_number })}
|
||||
</h1>
|
||||
<Badge variant={statusVariants[invoice.status] || 'secondary'}>
|
||||
@@ -605,29 +605,29 @@ export default function SupplierInvoiceDetailPage() {
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
|
||||
<span className="font-mono">{formatAmount(invoice.subtotal)} {invoice.currency}</span>
|
||||
<span className="tabular-nums">{formatAmount(invoice.subtotal)} {invoice.currency}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('vat_label')}</span>
|
||||
<span className="font-mono">{formatAmount(invoice.vat_amount)} {invoice.currency}</span>
|
||||
<span className="tabular-nums">{formatAmount(invoice.vat_amount)} {invoice.currency}</span>
|
||||
</div>
|
||||
{rounding.applies && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('ore_rounding')}</span>
|
||||
<span className="font-mono">{formatAmount(rounding.roundingDelta)} {invoice.currency}</span>
|
||||
<span className="tabular-nums">{formatAmount(rounding.roundingDelta)} {invoice.currency}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-bold text-base pt-2 border-t">
|
||||
<div className="flex justify-between font-semibold text-base pt-2 border-t">
|
||||
<span>{t('total_label')}</span>
|
||||
<span className="font-mono">{formatAmount(rounding.displayed)} {invoice.currency}</span>
|
||||
<span className="tabular-nums">{formatAmount(rounding.displayed)} {invoice.currency}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2">
|
||||
<span className="text-muted-foreground">{t('paid_label')}</span>
|
||||
<span className="font-mono text-success">{formatAmount(invoice.paid_amount)} {invoice.currency}</span>
|
||||
<span className="tabular-nums text-success">{formatAmount(invoice.paid_amount)} {invoice.currency}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>{t('remaining_label')}</span>
|
||||
<span className="font-mono">{formatAmount(invoice.remaining_amount)} {invoice.currency}</span>
|
||||
<span className="tabular-nums">{formatAmount(invoice.remaining_amount)} {invoice.currency}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -692,11 +692,11 @@ export default function SupplierInvoiceDetailPage() {
|
||||
</td>
|
||||
<td className="py-2 text-right">{item.quantity}</td>
|
||||
<td className="py-2">{item.unit}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(item.unit_price)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(item.unit_price)}</td>
|
||||
<td className="py-2"><AccountNumber number={item.account_number} /></td>
|
||||
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(item.line_total)}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(item.vat_amount)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(item.line_total)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(item.vat_amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -721,11 +721,11 @@ export default function SupplierInvoiceDetailPage() {
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{item.quantity} {item.unit} × {formatAmount(item.unit_price)}</span>
|
||||
<span className="font-mono">{formatAmount(item.line_total)} kr</span>
|
||||
<span className="tabular-nums">{formatAmount(item.line_total)} kr</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span><AccountNumber number={item.account_number} /> · {t('vat_inline', { rate: Math.round(item.vat_rate * 100) })}</span>
|
||||
<span className="font-mono">{t('vat_amount_inline', { amount: formatAmount(item.vat_amount) })}</span>
|
||||
<span className="tabular-nums">{t('vat_amount_inline', { amount: formatAmount(item.vat_amount) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -755,7 +755,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
{payments.map((p) => (
|
||||
<tr key={p.id} className="border-b last:border-0">
|
||||
<td className="py-2 tabular-nums">{formatDate(p.payment_date)}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(p.amount)} {p.currency}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(p.amount)} {p.currency}</td>
|
||||
<td className="py-2">
|
||||
{p.journal_entry_id ? (
|
||||
<Link href={`/bookkeeping/${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
|
||||
|
||||
@@ -1246,7 +1246,7 @@ export default function NewSupplierInvoicePage() {
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('page_title')}</h1>
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">{t('page_title')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1745,17 +1745,17 @@ export default function NewSupplierInvoicePage() {
|
||||
<div className="mt-4 pt-4 border-t flex flex-wrap gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai_totals_label')}</span>
|
||||
{extractedData.totals.subtotal != null && (
|
||||
<span className="px-2 py-1 rounded bg-muted font-mono">
|
||||
<span className="px-2 py-1 rounded bg-muted tabular-nums">
|
||||
{t('ai_net', { amount: formatAmount(extractedData.totals.subtotal) })}
|
||||
</span>
|
||||
)}
|
||||
{extractedData.totals.vatAmount != null && (
|
||||
<span className="px-2 py-1 rounded bg-muted font-mono">
|
||||
<span className="px-2 py-1 rounded bg-muted tabular-nums">
|
||||
{t('ai_vat', { amount: formatAmount(extractedData.totals.vatAmount) })}
|
||||
</span>
|
||||
)}
|
||||
{extractedData.totals.total != null && (
|
||||
<span className="px-2 py-1 rounded bg-muted font-mono">
|
||||
<span className="px-2 py-1 rounded bg-muted tabular-nums">
|
||||
{t('ai_total', { amount: formatAmount(extractedData.totals.total) })}
|
||||
</span>
|
||||
)}
|
||||
@@ -1766,17 +1766,17 @@ export default function NewSupplierInvoicePage() {
|
||||
<div className="mt-4 pt-4 border-t space-y-2">
|
||||
<div className="flex justify-between sm:justify-end sm:gap-8">
|
||||
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
|
||||
<span className="font-mono sm:w-32 text-right">{formatCurrency(subtotal, watchedCurrency)}</span>
|
||||
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(subtotal, watchedCurrency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between sm:justify-end sm:gap-8">
|
||||
<span className="text-muted-foreground">
|
||||
{watchedReverseCharge ? t('vat_reverse_charge') : t('vat_label_short')}
|
||||
</span>
|
||||
<span className="font-mono sm:w-32 text-right">{formatCurrency(totalVat, watchedCurrency)}</span>
|
||||
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(totalVat, watchedCurrency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between sm:justify-end sm:gap-8 font-bold text-lg">
|
||||
<span>{t('total_label')}</span>
|
||||
<span className="font-mono sm:w-32 text-right">{formatCurrency(total, watchedCurrency)}</span>
|
||||
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(total, watchedCurrency)}</span>
|
||||
</div>
|
||||
{/* Öresavrundning — display-only rounding of the displayed total to
|
||||
whole kronor (SEK only). The registered amount and the booked
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ToastAction } from '@/components/ui/toast'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import { DataList, DataListHeader, DataListEmpty } from '@/components/ui/data-list'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -1932,13 +1933,13 @@ export default function TransactionsPage() {
|
||||
{isLoading ? (
|
||||
<DataList>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 animate-pulse">
|
||||
<div className="h-5 w-5 rounded bg-muted" />
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-48 rounded bg-muted" />
|
||||
<div className="h-3 w-24 rounded bg-muted" />
|
||||
<Skeleton className="h-4 w-48 rounded" />
|
||||
<Skeleton className="h-3 w-24 rounded" />
|
||||
</div>
|
||||
<div className="h-5 w-20 rounded bg-muted" />
|
||||
<Skeleton className="h-5 w-20 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</DataList>
|
||||
@@ -2047,7 +2048,7 @@ export default function TransactionsPage() {
|
||||
|
||||
{/* Batch mode floating action bar */}
|
||||
{isBatchMode && selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-background border rounded-xl shadow-lg px-4 py-3">
|
||||
<div className="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-background border rounded-lg shadow-lg px-4 py-3">
|
||||
{batchProgress ? (
|
||||
<>
|
||||
<Badge variant="secondary">
|
||||
|
||||
@@ -205,7 +205,7 @@ export default function AttGoraSection({
|
||||
{bokforRows && (
|
||||
<div>
|
||||
<BandHeader>{t('band_bokfor')}</BandHeader>
|
||||
<div className="divide-y divide-border/50">
|
||||
<div className="divide-y divide-border">
|
||||
{counts.book_transaction > 0 && (
|
||||
<WorklistRow
|
||||
href="/transactions"
|
||||
@@ -305,7 +305,7 @@ export default function AttGoraSection({
|
||||
{granskaRows && (
|
||||
<div>
|
||||
<BandHeader>{t('band_granska')}</BandHeader>
|
||||
<div className="divide-y divide-border/50">
|
||||
<div className="divide-y divide-border">
|
||||
{counts.supplier_invoice_approval > 0 && (
|
||||
<WorklistRow
|
||||
href="/supplier-invoices"
|
||||
@@ -337,7 +337,7 @@ export default function AttGoraSection({
|
||||
{bevakaRows && (
|
||||
<div>
|
||||
<BandHeader>{t('band_bevaka')}</BandHeader>
|
||||
<div className="divide-y divide-border/50">
|
||||
<div className="divide-y divide-border">
|
||||
{counts.overdue_invoice > 0 && (
|
||||
<WorklistRow
|
||||
href="/invoices?status=unpaid"
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import type { Deadline, OnboardingProgress } from '@/types'
|
||||
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
|
||||
const setupFreshStartKey = (companyId: string) => `erp_setup_fresh_start:${companyId}`
|
||||
|
||||
@@ -115,8 +114,6 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
// header so the tile and the section never disagree.
|
||||
const todoCount = worklist.total + (summary.expiringBankConnections?.length ?? 0)
|
||||
|
||||
const slim = getBranding().navDensity === 'slim'
|
||||
|
||||
// Pick the single most-urgent next action so the launchpad surfaces one
|
||||
// unambiguous CTA. Order matches the friction we actually want to remove
|
||||
// first: stale → overdue → uncategorized → unpaid → all clear.
|
||||
@@ -202,8 +199,9 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
</Card>
|
||||
</Link>
|
||||
</section>
|
||||
) : slim ? (
|
||||
/* Next best action — single hero card */
|
||||
) : (
|
||||
/* Next best action — single hero card, shown for every agent-built
|
||||
company regardless of nav density so there is always one clear CTA */
|
||||
<section>
|
||||
<Link href={nextBestAction.href} className="block group">
|
||||
<Card className={cn(
|
||||
@@ -233,7 +231,7 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
</Card>
|
||||
</Link>
|
||||
</section>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* Key metrics — 4 compact cards */}
|
||||
<section>
|
||||
@@ -242,7 +240,7 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('result')}</p>
|
||||
<p className={cn(
|
||||
'font-display text-xl font-medium tabular-nums leading-tight',
|
||||
'font-display text-xl tabular-nums leading-tight',
|
||||
summary.mtd.net >= 0 ? 'text-success' : 'text-destructive'
|
||||
)}>
|
||||
{formatLargeNumber(summary.mtd.net)}
|
||||
@@ -261,7 +259,7 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('to_be_paid')}</p>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||
</div>
|
||||
<p className="font-display text-xl font-medium tabular-nums leading-tight">
|
||||
<p className="font-display text-xl tabular-nums leading-tight">
|
||||
{summary.unpaidInvoicesCount}
|
||||
{t('units') && <span className="text-sm ml-0.5 text-muted-foreground font-normal">{t('units')}</span>}
|
||||
</p>
|
||||
@@ -276,7 +274,7 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('bank_balance')}</p>
|
||||
<p className="font-display text-xl font-medium tabular-nums leading-tight">
|
||||
<p className="font-display text-xl tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.bankBalance)}
|
||||
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
@@ -301,7 +299,7 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('todo')}</p>
|
||||
<div role="status" aria-live="polite">
|
||||
{todoCount > 0 ? (
|
||||
<p className="font-display text-xl font-medium tabular-nums leading-tight text-warning-foreground">
|
||||
<p className="font-display text-xl tabular-nums leading-tight">
|
||||
{todoCount}
|
||||
{t('units') && <span className="text-sm ml-0.5 text-muted-foreground font-normal">{t('units')}</span>}
|
||||
</p>
|
||||
@@ -332,12 +330,12 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-3">{t('revenue')}</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
<p className="font-display text-2xl tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.income)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('this_month')}</p>
|
||||
<div className="mt-4 pt-3 border-t border-border/30 flex items-baseline justify-between">
|
||||
<div className="mt-4 pt-3 border-t border-border flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">{t('this_year_block')}</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
|
||||
</div>
|
||||
@@ -347,12 +345,12 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-3">{t('expenses')}</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
<p className="font-display text-2xl tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.expenses)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('this_month')}</p>
|
||||
<div className="mt-4 pt-3 border-t border-border/30 flex items-baseline justify-between">
|
||||
<div className="mt-4 pt-3 border-t border-border flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">{t('this_year_block')}</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
|
||||
</div>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-warning/30 bg-warning/5">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
@@ -134,18 +134,11 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
const isUpdating = updatingId === deadline.id
|
||||
const reportLink = getReportLink(deadline)
|
||||
const isOverdue = deadline.status === 'overdue'
|
||||
const isActionNeeded = deadline.status === 'action_needed'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deadline.id}
|
||||
className={`p-3 rounded-lg ${
|
||||
isOverdue
|
||||
? 'bg-destructive/5 border border-destructive/30'
|
||||
: isActionNeeded
|
||||
? 'bg-warning/5 border border-warning/20'
|
||||
: ''
|
||||
}`}
|
||||
className={`p-3 rounded-lg ${isOverdue ? 'bg-destructive/5' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
|
||||
@@ -129,11 +129,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
|
||||
<div
|
||||
key={deadline.id}
|
||||
className={`flex items-center justify-between p-2 rounded-lg ${
|
||||
deadline.status === 'overdue'
|
||||
? 'bg-destructive/5 border border-destructive/30'
|
||||
: deadline.status === 'action_needed'
|
||||
? 'bg-warning/5 border border-warning/20'
|
||||
: ''
|
||||
deadline.status === 'overdue' ? 'bg-destructive/5' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
|
||||
@@ -6,6 +6,13 @@ import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import AiFilledIndicator from '@/components/ui/ai-filled-indicator'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
Inbox,
|
||||
@@ -24,6 +31,7 @@ import {
|
||||
Search,
|
||||
Circle,
|
||||
X,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
@@ -664,7 +672,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="xl:h-full flex flex-col rounded-lg border bg-card xl:overflow-hidden shadow-sm">
|
||||
<div className="xl:h-full flex flex-col rounded-lg border bg-card xl:overflow-hidden">
|
||||
{/* Top bar */}
|
||||
<header className="flex items-center justify-between gap-4 border-b px-4 py-2.5 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -1084,10 +1092,10 @@ function InboxRow({
|
||||
<AlertTriangle className="h-3 w-3 text-destructive shrink-0" aria-label="Fel vid bearbetning" />
|
||||
)}
|
||||
{isLinkedToTransaction && (
|
||||
<Link2 className="h-3 w-3 text-emerald-600 shrink-0" aria-label="Kopplad till transaktion" />
|
||||
<Link2 className="h-3 w-3 text-success shrink-0" aria-label="Kopplad till transaktion" />
|
||||
)}
|
||||
{isBooked && (
|
||||
<Check className="h-3 w-3 text-emerald-600 shrink-0" aria-label="Bokförd" />
|
||||
<Check className="h-3 w-3 text-success shrink-0" aria-label="Bokförd" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
@@ -1144,7 +1152,7 @@ export function DocumentPreview({
|
||||
<div className="h-full w-full p-4 flex items-start justify-center overflow-hidden">
|
||||
{docMime?.startsWith('image/') ? (
|
||||
// Image: frame hugs the image, capped at the parent's visible box.
|
||||
<div className="max-h-full max-w-3xl bg-background rounded-md border shadow-sm overflow-hidden flex">
|
||||
<div className="max-h-full max-w-3xl bg-background rounded-md border overflow-hidden flex">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={docUrl}
|
||||
@@ -1154,7 +1162,7 @@ export function DocumentPreview({
|
||||
</div>
|
||||
) : (
|
||||
// PDF: iframe needs explicit height — frame fills the available pane.
|
||||
<div className="h-full w-full max-w-3xl bg-background rounded-md border shadow-sm overflow-hidden">
|
||||
<div className="h-full w-full max-w-3xl bg-background rounded-md border overflow-hidden">
|
||||
<iframe src={docUrl} className="w-full h-full border-0" title="Underlag" />
|
||||
</div>
|
||||
)}
|
||||
@@ -1211,7 +1219,7 @@ function OnboardingCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-xl border bg-card shadow-sm',
|
||||
'relative rounded-lg border bg-card',
|
||||
compact ? 'mx-3 my-3 p-4 text-xs' : 'max-w-md mx-auto p-6'
|
||||
)}
|
||||
>
|
||||
@@ -1228,7 +1236,7 @@ function OnboardingCard({
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2
|
||||
className={cn(
|
||||
'font-display font-medium tracking-tight',
|
||||
'font-display tracking-tight',
|
||||
compact ? 'text-sm' : 'text-lg'
|
||||
)}
|
||||
>
|
||||
@@ -1266,7 +1274,7 @@ function OnboardingCard({
|
||||
>
|
||||
<span className="shrink-0 mt-0.5">
|
||||
{isDone ? (
|
||||
<Check className={cn('text-emerald-600', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
|
||||
<Check className={cn('text-success', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
|
||||
) : isCurrent ? (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -1647,19 +1655,36 @@ function FieldsRail({
|
||||
>
|
||||
Matcha mot transaktion
|
||||
</Button>
|
||||
<Link href={`/supplier-invoices/new?inbox_item_id=${item.id}`} className="block">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
Skapa leverantörsfaktura
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onBookDirect}
|
||||
>
|
||||
Bokför som verifikat
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full justify-between">
|
||||
Andra sätt att bokföra
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/supplier-invoices/new?inbox_item_id=${item.id}`}
|
||||
className="flex flex-col items-start gap-1"
|
||||
>
|
||||
<span>Skapa leverantörsfaktura</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
För leverantörsskulder du vill följa (periodisering).
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onBookDirect}
|
||||
className="flex flex-col items-start gap-1"
|
||||
>
|
||||
<span>Bokför som verifikat</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
För underlag som inte är en leverantörsfaktura (bankavgift, utlägg).
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
@@ -1802,6 +1827,10 @@ export function EditableFieldsList({
|
||||
const [drafts, setDrafts] = useState<Record<FieldKey, string>>(() =>
|
||||
Object.fromEntries(FIELD_DEFS.map((f) => [f.key, readField(data, f.key)])) as Record<FieldKey, string>
|
||||
)
|
||||
// Per-field provenance: a populated field starts "AI-filled" (its value came
|
||||
// from the extraction) and flips to user-verified once the user edits it —
|
||||
// mirrors the create form's AiFilledIndicator. Reset when switching items.
|
||||
const [edited, setEdited] = useState<Partial<Record<FieldKey, boolean>>>({})
|
||||
const timersRef = useRef<Partial<Record<FieldKey, ReturnType<typeof setTimeout>>>>({})
|
||||
// Last-known server values per field. Used to detect when the server
|
||||
// normalises a value (currency upper-cased, whitespace trimmed) so we can
|
||||
@@ -1818,6 +1847,7 @@ export function EditableFieldsList({
|
||||
) as Record<FieldKey, string>
|
||||
setDrafts(seeded)
|
||||
lastServerRef.current = seeded
|
||||
setEdited({})
|
||||
return () => {
|
||||
for (const t of Object.values(timersRef.current)) {
|
||||
if (t) clearTimeout(t)
|
||||
@@ -1905,6 +1935,7 @@ export function EditableFieldsList({
|
||||
const onChange = useCallback(
|
||||
(key: FieldKey, raw: string) => {
|
||||
setDrafts((prev) => ({ ...prev, [key]: raw }))
|
||||
setEdited((prev) => (prev[key] ? prev : { ...prev, [key]: true }))
|
||||
const existing = timersRef.current[key]
|
||||
if (existing) clearTimeout(existing)
|
||||
timersRef.current[key] = setTimeout(() => {
|
||||
@@ -1935,12 +1966,18 @@ export function EditableFieldsList({
|
||||
<div className="space-y-2">
|
||||
{FIELD_DEFS.map((f) => (
|
||||
<div key={f.key} className="flex flex-col gap-0.5">
|
||||
<label
|
||||
htmlFor={`field-${f.key}`}
|
||||
className="text-[10px] uppercase tracking-wide text-muted-foreground/80"
|
||||
>
|
||||
{f.label}
|
||||
</label>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label
|
||||
htmlFor={`field-${f.key}`}
|
||||
className="text-[10px] uppercase tracking-wide text-muted-foreground/80"
|
||||
>
|
||||
{f.label}
|
||||
</label>
|
||||
<AiFilledIndicator
|
||||
active={drafts[f.key].trim() !== '' && !edited[f.key]}
|
||||
title="Ifyllt av AI — kontrollera mot dokumentet"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id={`field-${f.key}`}
|
||||
type={f.type}
|
||||
|
||||
@@ -437,7 +437,7 @@ export default function PaymentBookingDialog({
|
||||
placeholder="0,00"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
||||
className="font-mono text-right"
|
||||
className="tabular-nums text-right"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
@@ -450,7 +450,7 @@ export default function PaymentBookingDialog({
|
||||
placeholder="0,00"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
||||
className="font-mono text-right"
|
||||
className="tabular-nums text-right"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
@@ -489,7 +489,7 @@ export default function PaymentBookingDialog({
|
||||
placeholder="0,00"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
||||
className="font-mono text-right"
|
||||
className="tabular-nums text-right"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -498,7 +498,7 @@ export default function PaymentBookingDialog({
|
||||
placeholder="0,00"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
||||
className="font-mono text-right"
|
||||
className="tabular-nums text-right"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -530,7 +530,7 @@ export default function PaymentBookingDialog({
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isBalanced ? (
|
||||
<Badge variant="secondary" className="bg-success/10 text-success">
|
||||
<Badge variant="success">
|
||||
{t('balanced_badge')}
|
||||
</Badge>
|
||||
) : (
|
||||
@@ -539,7 +539,7 @@ export default function PaymentBookingDialog({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground font-mono">
|
||||
<div className="text-sm text-muted-foreground tabular-nums">
|
||||
{formatCurrency(totalDebit)} / {formatCurrency(totalCredit)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
@@ -78,7 +79,7 @@ export default function NewUserChecklist({
|
||||
</div>
|
||||
|
||||
{hasBookkeepingImported ? (
|
||||
<div className="ml-0 sm:ml-10 p-4 sm:p-5 rounded-lg border border-border bg-secondary/40">
|
||||
<div className="ml-0 sm:ml-10 p-4 sm:p-6 rounded-lg border border-border bg-secondary/40">
|
||||
<p className="text-sm text-foreground font-medium">{t('step1_done_title')}</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">{t('step1_done_description')}</p>
|
||||
</div>
|
||||
@@ -87,20 +88,20 @@ export default function NewUserChecklist({
|
||||
{hasMigration && (
|
||||
<Link
|
||||
href="/import?mode=migration"
|
||||
className="group block p-4 sm:p-5 rounded-lg border border-border bg-secondary/40 hover:bg-secondary/60 hover:border-primary/40 transition-colors duration-150"
|
||||
className="group block p-4 sm:p-6 rounded-lg border border-border bg-secondary/40 hover:bg-secondary/60 hover:border-primary/40 transition-colors duration-150"
|
||||
>
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="p-2 sm:p-2.5 rounded-lg bg-primary/[0.08] group-hover:bg-primary/[0.12] transition-colors flex-shrink-0">
|
||||
<div className="p-2 sm:p-3 rounded-lg bg-primary/[0.08] group-hover:bg-primary/[0.12] transition-colors flex-shrink-0">
|
||||
<ArrowRightLeft className="h-4 w-4 sm:h-5 sm:w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
|
||||
{t('migrate_title')}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed underline decoration-foreground/20 underline-offset-2">
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed underline decoration-foreground/20 underline-offset-2">
|
||||
{t('migrate_description')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 sm:gap-2 mt-2.5 sm:mt-3">
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{([
|
||||
{ name: 'Fortnox', logo: '/logos/fortnox.svg' },
|
||||
{ name: 'Visma', logo: '/logos/visma.jpeg' },
|
||||
@@ -109,7 +110,7 @@ export default function NewUserChecklist({
|
||||
{ name: 'Briox', logo: '/logos/Briox_logo.png' },
|
||||
{ name: 'SIE4-fil', logo: null },
|
||||
] as const).map(provider => (
|
||||
<div key={provider.name} className="flex items-center gap-1 sm:gap-1.5 rounded border border-border bg-muted/30 px-1.5 sm:px-2 py-0.5 sm:py-1">
|
||||
<div key={provider.name} className="flex items-center gap-1 rounded border border-border bg-muted/30 px-2 py-1">
|
||||
{provider.logo ? (
|
||||
<img src={provider.logo} alt={provider.name} className="h-3.5 w-3.5 sm:h-4 sm:w-4 shrink-0 rounded-sm object-contain" />
|
||||
) : (
|
||||
@@ -127,17 +128,17 @@ export default function NewUserChecklist({
|
||||
|
||||
<Link
|
||||
href="/import?mode=sie"
|
||||
className="group block p-4 sm:p-5 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
|
||||
className="group block p-4 sm:p-6 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
|
||||
>
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="p-2 sm:p-2.5 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
|
||||
<div className="p-2 sm:p-3 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
|
||||
<FileText className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
|
||||
{t('sie_title')}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed">
|
||||
{t('sie_description')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -166,24 +167,24 @@ export default function NewUserChecklist({
|
||||
|
||||
<div className="ml-0 sm:ml-10">
|
||||
{hasBankConnected ? (
|
||||
<div className="p-4 sm:p-5 rounded-lg border border-border bg-secondary/40">
|
||||
<div className="p-4 sm:p-6 rounded-lg border border-border bg-secondary/40">
|
||||
<p className="text-sm text-foreground font-medium">Bank kopplad</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">Transaktioner synkas automatiskt.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={hasBanking ? '/import?mode=psd2' : '/import?mode=bank'}
|
||||
className="group block p-4 sm:p-5 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
|
||||
className="group block p-4 sm:p-6 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
|
||||
>
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="p-2 sm:p-2.5 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
|
||||
<div className="p-2 sm:p-3 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
|
||||
<Landmark className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
|
||||
{t('bank_title')}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed">
|
||||
{hasBanking
|
||||
? t('bank_description_psd2')
|
||||
: t('bank_description_file')}
|
||||
@@ -223,16 +224,16 @@ export default function NewUserChecklist({
|
||||
|
||||
<div className="ml-0 sm:ml-10">
|
||||
{hasSkatteverketConnected ? (
|
||||
<div className="block p-4 sm:p-5 rounded-lg border border-border bg-secondary/40">
|
||||
<div className="block p-4 sm:p-6 rounded-lg border border-border bg-secondary/40">
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="p-2 sm:p-2.5 rounded-lg bg-background flex-shrink-0">
|
||||
<div className="p-2 sm:p-3 rounded-lg bg-background flex-shrink-0">
|
||||
<FileCheck className="h-4 w-4 sm:h-5 sm:w-5 text-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm sm:text-base text-foreground">
|
||||
{t('skatteverket_connected_title')}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed">
|
||||
{t('skatteverket_connected_description')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -245,17 +246,17 @@ export default function NewUserChecklist({
|
||||
// skatteverket.se; <Link> would route via Next's client
|
||||
// router which doesn't follow cross-origin redirects.
|
||||
href="/api/extensions/ext/skatteverket/authorize?return_to=/"
|
||||
className="group block p-4 sm:p-5 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
|
||||
className="group block p-4 sm:p-6 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
|
||||
>
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="p-2 sm:p-2.5 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
|
||||
<div className="p-2 sm:p-3 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
|
||||
<FileCheck className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
|
||||
{t('skatteverket_connect_title')}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed">
|
||||
{t('skatteverket_connect_description', { appName: branding.appName.toLowerCase() })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -286,16 +287,16 @@ export default function NewUserChecklist({
|
||||
|
||||
<div className="ml-0 sm:ml-10">
|
||||
{hasAgentBuilt ? (
|
||||
<div className="p-4 sm:p-5 rounded-lg border border-border bg-secondary/40">
|
||||
<div className="p-4 sm:p-6 rounded-lg border border-border bg-secondary/40">
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="p-2 sm:p-2.5 rounded-lg bg-background flex-shrink-0">
|
||||
<div className="p-2 sm:p-3 rounded-lg bg-background flex-shrink-0">
|
||||
<MessageCircle className="h-4 w-4 sm:h-5 sm:w-5 text-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm sm:text-base text-foreground">
|
||||
Assistenten är klar
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed">
|
||||
Du kan börja chatta direkt. Justera tonalitet och kunskap i Inställningar > Assistentens minne.
|
||||
</p>
|
||||
</div>
|
||||
@@ -304,10 +305,10 @@ export default function NewUserChecklist({
|
||||
) : (
|
||||
<Link
|
||||
href="/onboarding/agent"
|
||||
className="group block p-4 sm:p-5 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
|
||||
className="group block p-4 sm:p-6 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
|
||||
>
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="p-2 sm:p-2.5 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
|
||||
<div className="p-2 sm:p-3 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
|
||||
<MessageCircle className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -317,7 +318,7 @@ export default function NewUserChecklist({
|
||||
</p>
|
||||
<Badge variant="secondary" className="uppercase tracking-wider">Beta</Badge>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed">
|
||||
Några frågor om din verksamhet kalibrerar tonalitet, signatur och vad assistenten kan. Ju mer du delar, desto bättre förstår den dig.
|
||||
</p>
|
||||
</div>
|
||||
@@ -328,22 +329,20 @@ export default function NewUserChecklist({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Escape hatch */}
|
||||
<div className="space-y-5">
|
||||
{/* Escape hatch — a visible secondary action, not a muted ghost link,
|
||||
so users with nothing to import can clearly choose to start fresh. */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 h-px bg-border/60" />
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-xs text-muted-foreground">{t('or_separator')}</span>
|
||||
<div className="flex-1 h-px bg-border/60" />
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={onFreshStart}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1.5 group"
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={onFreshStart} className="group">
|
||||
{t('fresh_start')}
|
||||
<ArrowRight className="h-3.5 w-3.5 group-hover:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-0.5 transition-transform" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
|
||||
@@ -8,6 +8,21 @@ import { Search, X } from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
|
||||
/**
|
||||
* Map the endpoint's 0–1 match confidence (attached only when candidates are
|
||||
* ranked for a specific transaction) to a labelled strength badge, so the user
|
||||
* can tell an exact-amount hit from a fuzzy guess before vouching for an
|
||||
* immutable verifikat. Returns null when no confidence was attached.
|
||||
*/
|
||||
function confidenceBadge(
|
||||
confidence: number | undefined,
|
||||
): { label: string; variant: 'success' | 'secondary' | 'outline' } | null {
|
||||
if (confidence == null) return null
|
||||
if (confidence >= 0.85) return { label: 'Stark träff', variant: 'success' }
|
||||
if (confidence >= 0.6) return { label: 'Trolig träff', variant: 'secondary' }
|
||||
return { label: 'Svag träff', variant: 'outline' }
|
||||
}
|
||||
|
||||
/**
|
||||
* A posted journal entry line on a cash account (e.g. 1930) not yet linked to
|
||||
* any bank transaction — a candidate for manual reconciliation. Mirrors the
|
||||
@@ -111,14 +126,24 @@ export function MatchVerifikationPicker({
|
||||
|
||||
if (selected) {
|
||||
const amount = selected.debit_amount > 0 ? selected.debit_amount : -selected.credit_amount
|
||||
// Suppress the match-strength badge on an already-matched verifikat so a
|
||||
// green "Stark träff" can't visually encourage an accidental double-match —
|
||||
// "Redan matchad" is the signal that matters there (N:1 stays opt-in).
|
||||
const strength =
|
||||
(selected.linked_transaction_count ?? 0) > 0 ? null : confidenceBadge(selected.confidence)
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/40 px-3 py-2 text-sm">
|
||||
<span className="font-mono text-xs shrink-0">{formatVoucher(selected)}</span>
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums">{formatDate(selected.entry_date)}</span>
|
||||
<span className="font-mono tabular-nums shrink-0">{formatCurrency(amount)}</span>
|
||||
<span className="truncate text-muted-foreground">{selected.entry_description}</span>
|
||||
<span className="tabular-nums shrink-0">{formatCurrency(amount)}</span>
|
||||
<span className="truncate text-muted-foreground flex-1 min-w-0">{selected.entry_description}</span>
|
||||
{strength && (
|
||||
<Badge variant={strength.variant} className="shrink-0 text-[10px]">
|
||||
{strength.label}
|
||||
</Badge>
|
||||
)}
|
||||
{(selected.linked_transaction_count ?? 0) > 0 && (
|
||||
<Badge variant="secondary" className="ml-auto shrink-0 text-[10px]">
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||
Redan matchad
|
||||
</Badge>
|
||||
)}
|
||||
@@ -126,7 +151,7 @@ export function MatchVerifikationPicker({
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={`${(selected.linked_transaction_count ?? 0) > 0 ? '' : 'ml-auto'} h-6 w-6 shrink-0`}
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => onChange('')}
|
||||
disabled={disabled}
|
||||
aria-label="Avmarkera verifikation"
|
||||
@@ -147,6 +172,8 @@ export function MatchVerifikationPicker({
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{filtered.map((line) => {
|
||||
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
|
||||
const strength =
|
||||
(line.linked_transaction_count ?? 0) > 0 ? null : confidenceBadge(line.confidence)
|
||||
return (
|
||||
<button
|
||||
key={line.line_id}
|
||||
@@ -166,10 +193,15 @@ export function MatchVerifikationPicker({
|
||||
>
|
||||
<span className="font-mono text-xs shrink-0 w-12">{formatVoucher(line)}</span>
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums w-24">{formatDate(line.entry_date)}</span>
|
||||
<span className="font-mono tabular-nums shrink-0 w-24 text-right">{formatCurrency(amount)}</span>
|
||||
<span className="tabular-nums shrink-0 w-24 text-right">{formatCurrency(amount)}</span>
|
||||
<span className="truncate text-muted-foreground flex-1">
|
||||
{line.line_description || line.entry_description}
|
||||
</span>
|
||||
{strength && (
|
||||
<Badge variant={strength.variant} className="shrink-0 text-[10px]">
|
||||
{strength.label}
|
||||
</Badge>
|
||||
)}
|
||||
{(line.linked_transaction_count ?? 0) > 0 && (
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||
Matchad
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal } from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
@@ -543,8 +545,9 @@ export function BankReconciliationView() {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar bankavstämning...
|
||||
<CardContent className="p-6 space-y-3">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -577,12 +580,12 @@ export function BankReconciliationView() {
|
||||
|
||||
{/* Status Card */}
|
||||
{status && (
|
||||
<Card className="border-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Avstämning mot <AccountNumber number={accountNumber} /></CardTitle>
|
||||
{status.is_reconciled ? (
|
||||
<Badge className="bg-success/10 text-success">Avstämd</Badge>
|
||||
<Badge variant="success">Avstämd</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej avstämd</Badge>
|
||||
)}
|
||||
@@ -595,11 +598,11 @@ export function BankReconciliationView() {
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Banktransaktioner i perioden</span>
|
||||
<span className="font-mono">{formatCurrency(status.bank_transaction_total)}</span>
|
||||
<span className="tabular-nums">{formatCurrency(status.bank_transaction_total)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Bokfört på <AccountNumber number={accountNumber} /> i perioden</span>
|
||||
<span className="font-mono">
|
||||
<span className="tabular-nums">
|
||||
{formatCurrency(status.gl_1930_period_movement)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -612,14 +615,14 @@ export function BankReconciliationView() {
|
||||
{status.gl_1930_opening_balance !== 0 && (
|
||||
<p className="pt-2 text-xs text-muted-foreground">
|
||||
Ingående balans (IB) på <AccountNumber number={accountNumber} />:{' '}
|
||||
<span className="font-mono">{formatCurrency(status.gl_1930_opening_balance)}</span>
|
||||
<span className="tabular-nums">{formatCurrency(status.gl_1930_opening_balance)}</span>
|
||||
{' '}— räknas inte i avstämningen.
|
||||
</p>
|
||||
)}
|
||||
{status.gl_1930_correction_adjustment !== 0 && (
|
||||
<p className="pt-2 text-xs text-muted-foreground">
|
||||
Varav rättelser och stornon på <AccountNumber number={accountNumber} /> i perioden:{' '}
|
||||
<span className="font-mono">{formatCurrency(status.gl_1930_correction_adjustment)}</span>
|
||||
<span className="tabular-nums">{formatCurrency(status.gl_1930_correction_adjustment)}</span>
|
||||
{' '}— ingår i det bokförda beloppet och i avstämningen, precis som i balansräkningen.
|
||||
</p>
|
||||
)}
|
||||
@@ -643,20 +646,20 @@ export function BankReconciliationView() {
|
||||
/>
|
||||
<div>
|
||||
<Label>Datum från</Label>
|
||||
<input
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Datum till</Label>
|
||||
<input
|
||||
<Input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={fetchAll} variant="outline">
|
||||
@@ -703,7 +706,7 @@ export function BankReconciliationView() {
|
||||
<tr key={m.transaction_id} className="border-b last:border-0">
|
||||
<td className="py-2 truncate max-w-[180px]">{m.transaction_description}</td>
|
||||
<td className="py-2 tabular-nums">{formatDate(m.transaction_date)}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(m.transaction_amount)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(m.transaction_amount)}</td>
|
||||
<td className="py-2 text-center text-muted-foreground">↔</td>
|
||||
<td className="py-2">
|
||||
<span className="font-mono text-xs">{formatVoucher(m)}</span>
|
||||
@@ -940,7 +943,7 @@ export function BankReconciliationView() {
|
||||
<td className="py-2 truncate max-w-[300px]">
|
||||
{line.line_description || line.entry_description}
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono">
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatCurrency(amount)}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-muted-foreground">{line.source_type}</td>
|
||||
@@ -1008,7 +1011,7 @@ export function BankReconciliationView() {
|
||||
<td className="py-2 text-xs">
|
||||
<Badge variant="outline" className="text-xs">{tx.currency}</Badge>
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono">
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatCurrency(tx.amount)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
@@ -1065,7 +1068,7 @@ export function BankReconciliationView() {
|
||||
<tr key={tx.id} className="border-b last:border-0">
|
||||
<td className="py-2">{tx.date}</td>
|
||||
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
|
||||
<td className="py-2 text-right font-mono">
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatCurrency(tx.amount)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
|
||||
@@ -259,7 +259,7 @@ export function SupplierInvoiceReviewContent({
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 sm:gap-2 shrink-0">
|
||||
{reverseCharge && (
|
||||
<Badge variant="outline" className="border-orange-300 text-orange-700 dark:text-orange-400">
|
||||
<Badge variant="warning">
|
||||
{t('reverse_charge_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -333,9 +333,9 @@ export function SupplierInvoiceReviewContent({
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(item.amount)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(item.amount)}</td>
|
||||
<td className="py-2 text-right">{Math.round(displayRate * 100)}%</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(vatAmount)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(vatAmount)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
@@ -408,7 +408,7 @@ export function SupplierInvoiceReviewContent({
|
||||
)}
|
||||
</p>
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm font-mono">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="text-left">
|
||||
<th className="pb-1 w-16">{t('col_account')}</th>
|
||||
@@ -426,10 +426,10 @@ export function SupplierInvoiceReviewContent({
|
||||
<td className="py-1 text-xs">
|
||||
{ACCOUNT_LABELS[line.account_number] || line.description}
|
||||
</td>
|
||||
<td className="py-1 text-right">
|
||||
<td className="py-1 text-right tabular-nums">
|
||||
{line.debit > 0 ? formatAmount(line.debit) : ''}
|
||||
</td>
|
||||
<td className="py-1 text-right">
|
||||
<td className="py-1 text-right tabular-nums">
|
||||
{line.credit > 0 ? formatAmount(line.credit) : ''}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -438,8 +438,8 @@ export function SupplierInvoiceReviewContent({
|
||||
<tfoot>
|
||||
<tr className="border-t font-semibold">
|
||||
<td className="pt-1" colSpan={2}>{t('sum_label')}</td>
|
||||
<td className="pt-1 text-right">{formatAmount(totalDebit)}</td>
|
||||
<td className="pt-1 text-right">{formatAmount(totalCredit)}</td>
|
||||
<td className="pt-1 text-right tabular-nums">{formatAmount(totalDebit)}</td>
|
||||
<td className="pt-1 text-right tabular-nums">{formatAmount(totalCredit)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
@@ -455,12 +455,12 @@ export function SupplierInvoiceReviewContent({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono text-xs shrink-0 ml-2">
|
||||
<span className="tabular-nums text-xs shrink-0 ml-2">
|
||||
{line.debit > 0 ? t('debit_short', { amount: formatAmount(line.debit) }) : t('credit_short', { amount: formatAmount(line.credit) })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between pt-1 border-t font-semibold text-xs font-mono">
|
||||
<div className="flex justify-between pt-1 border-t font-semibold text-xs tabular-nums">
|
||||
<span>{t('sum_label')}</span>
|
||||
<span>{t('debit_credit_short', { debit: formatAmount(totalDebit), credit: formatAmount(totalCredit) })}</span>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Check, Upload, Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
@@ -13,59 +13,38 @@ interface InboxZeroStateProps {
|
||||
|
||||
export default function InboxZeroState({ hasTransactions, onCreateTransaction }: InboxZeroStateProps) {
|
||||
const t = useTranslations('tx_inbox_zero')
|
||||
|
||||
if (!hasTransactions) {
|
||||
// No transactions at all
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="p-5 rounded-full bg-muted mb-6">
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">{t('empty_title')}</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm mb-6">
|
||||
{t('empty_description')}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto px-4 sm:px-0">
|
||||
<Button asChild>
|
||||
<Link href="/import">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t('import_btn')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onCreateTransaction}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('add_manual_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EmptyState icon={Upload} title={t('empty_title')} description={t('empty_description')}>
|
||||
<Button asChild>
|
||||
<Link href="/import">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t('import_btn')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onCreateTransaction}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('add_manual_btn')}
|
||||
</Button>
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
// All transactions categorized - inbox zero!
|
||||
// All transactions categorized — inbox zero!
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mb-4">
|
||||
<Check className="h-8 w-8 text-success" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">{t('done_title')}</h3>
|
||||
<p className="text-muted-foreground text-center mt-1 max-w-sm">
|
||||
{t('done_description')}
|
||||
</p>
|
||||
<div className="flex gap-2 mt-6">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/import">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t('import_more_btn')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onCreateTransaction}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('new_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EmptyState icon={Check} title={t('done_title')} description={t('done_description')}>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/import">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t('import_more_btn')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onCreateTransaction}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('new_btn')}
|
||||
</Button>
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ export default function InvoiceMatchDialog({
|
||||
{t('link_to_existing', { label: candidate.voucher_label })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleConfirm({
|
||||
@@ -355,7 +355,7 @@ export default function InvoiceMatchDialog({
|
||||
})
|
||||
}
|
||||
disabled={isConfirming}
|
||||
className="sm:flex-1"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t('create_new_anyway')}
|
||||
</Button>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
MatchVerifikationPicker,
|
||||
@@ -173,7 +172,7 @@ export function MatchVoucherDialog({
|
||||
const result = await res.json()
|
||||
if (!res.ok || result.error) {
|
||||
toast({
|
||||
title: 'Kunde inte koppla',
|
||||
title: 'Kunde inte matcha',
|
||||
description: getErrorMessage(result, { context: 'transaction', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -245,11 +244,6 @@ export function MatchVoucherDialog({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{selectedLine &&
|
||||
(selectedLine.confidence ?? 0) >= 0.85 &&
|
||||
!(selectedLine.linked_transaction_count ?? 0) && (
|
||||
<Badge variant="success" className="mb-1">Föreslagen träff</Badge>
|
||||
)}
|
||||
<MatchVerifikationPicker glLines={glLines} value={selected} onChange={setSelected} inline />
|
||||
{(selectedLine?.linked_transaction_count ?? 0) > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -293,10 +287,10 @@ export function MatchVoucherDialog({
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Kopplar…
|
||||
Matchar…
|
||||
</>
|
||||
) : (
|
||||
'Koppla'
|
||||
'Matcha'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -212,8 +212,8 @@ export default function TransactionBookingDialog({
|
||||
<div
|
||||
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
isIncome
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
? 'text-success'
|
||||
: 'text-destructive'
|
||||
}`}
|
||||
>
|
||||
{isIncome ? (
|
||||
@@ -237,7 +237,7 @@ export default function TransactionBookingDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadZone(!showUploadZone)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
className="flex items-center justify-between w-full px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
|
||||
@@ -446,7 +446,7 @@ export default function TransactionInboxCard({
|
||||
{HAS_AI_EXTRACTION &&
|
||||
!isBatchMode &&
|
||||
(extraction.status === 'running' || extraction.status === 'failed') && (
|
||||
<div className="mt-2 pt-2 border-t border-border/40">
|
||||
<div className="mt-2 pt-2 border-t border-border">
|
||||
<ExtractionStatus
|
||||
status={extraction.status}
|
||||
elapsedMs={extraction.elapsedMs}
|
||||
|
||||
Reference in New Issue
Block a user