feat: support contact links and SIE import UX polish (#138)
* feat: event log, pending operations, and MCP staging - Event log system: persist bus events to event_log table for external automation platforms. Batch insert for transaction.synced. Daily cleanup cron at 02:00 UTC. - Pending operations: MCP write tools (categorize, create customer, create invoice) now stage to pending_operations instead of executing directly. Users review and commit/reject from /pending in the web UI. - Granskning page: card-based review UI with expandable previews, commit/reject dialogs. Only shown in nav when pending ops exist. - Commit route re-executes using core lib functions (no extension imports). Guards against stale state (double-commit, deleted entities). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: stage new MCP write tools after main merge Add staging for 4 new write tools from #133: - mark_invoice_paid, send_invoice, mark_invoice_sent, match_transaction_invoice - Expand pending_operations CHECK constraint - Add commit executors with full execution logic - Add UI labels and generic preview component - Remove confirm parameter from categorize (single-call staging) - Fix UUID in pending op title (fetch transaction description) - Hide Granskning nav when no pending ops Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review feedback - Fix TS build error: use `select('*, customer:customers(*)')` for match_transaction_invoice to avoid array type inference - Add status guard to commitSendInvoice (prevents duplicate sends) - Replace auth.admin.getUserById with user email from session auth - Restore optimistic lock check in commitMatchTransactionInvoice - Fix tool description typo: expense_software → expense_office Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add support contact links and improve SIE import UX Add a SupportLink component with a contact dialog throughout the app (nav, help page, settings, MFA, error pages, empty states). Improve SIE import flow with phased loading states, structured skip breakdowns, and an elapsed-time counter. Fix MFA enroll stale factor cleanup and URL encoding for settings return path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — open redirect, XSS, test cleanup, fallback email - Validate returnTo is a relative path in MFA enroll (prevents open redirect) - Add afterEach import to event-log-handler tests (fixes handler leak) - HTML-escape user-supplied subject and message in support email body - Replace hardcoded personal email with support@gnubok.se fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f3ae3cd361
commit
5cb8c3c1e4
@@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, ShieldCheck, Copy, Check } from 'lucide-react'
|
||||
import { Loader2, ShieldCheck, Copy, Check, ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function MfaEnrollPage() {
|
||||
return (
|
||||
@@ -31,12 +31,23 @@ function MfaEnrollContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const supabase = createClient()
|
||||
|
||||
const returnTo = searchParams.get('returnTo') || '/'
|
||||
const rawReturnTo = searchParams.get('returnTo') || '/'
|
||||
const returnTo = rawReturnTo.startsWith('/') && !rawReturnTo.startsWith('//') ? rawReturnTo : '/'
|
||||
|
||||
const handleEnroll = async () => {
|
||||
setIsEnrolling(true)
|
||||
|
||||
try {
|
||||
// Clean up any stale unverified factors from previous abandoned attempts
|
||||
const { data: existingFactors } = await supabase.auth.mfa.listFactors()
|
||||
if (existingFactors?.totp) {
|
||||
for (const factor of existingFactors.totp) {
|
||||
if (factor.status !== 'verified') {
|
||||
await supabase.auth.mfa.unenroll({ factorId: factor.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.mfa.enroll({
|
||||
factorType: 'totp',
|
||||
friendlyName: 'gnubok',
|
||||
@@ -45,7 +56,7 @@ function MfaEnrollContent() {
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Kunde inte aktivera 2FA',
|
||||
description: 'Försök igen senare.',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsEnrolling(false)
|
||||
@@ -174,6 +185,15 @@ function MfaEnrollContent() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full mt-4 text-muted-foreground"
|
||||
onClick={() => router.push(returnTo)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -264,6 +284,15 @@ function MfaEnrollContent() {
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full mt-4 text-muted-foreground"
|
||||
onClick={() => router.push(returnTo)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, ShieldCheck, LogOut } from 'lucide-react'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
|
||||
export default function MfaVerifyPage() {
|
||||
const [code, setCode] = useState('')
|
||||
@@ -151,6 +152,13 @@ export default function MfaVerifyPage() {
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Logga ut
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center mt-4">
|
||||
Förlorat din autentiseringsapp?{' '}
|
||||
<SupportLink variant="muted" subject="MFA-problem — kan inte logga in" className="inline">
|
||||
Kontakta support
|
||||
</SupportLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from 'next/link'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, Lock } from 'lucide-react'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
|
||||
export default function YearEndPage() {
|
||||
return (
|
||||
@@ -25,6 +26,11 @@ export default function YearEndPage() {
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Årsbokslut är under utveckling och kommer att finnas tillgängligt i en kommande version.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<SupportLink variant="muted" subject="Fråga om årsbokslut">
|
||||
Behöver du hjälp med bokslut? Kontakta oss
|
||||
</SupportLink>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
|
||||
export default function DashboardError({
|
||||
error,
|
||||
@@ -18,8 +19,9 @@ export default function DashboardError({
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4">
|
||||
<h2 className="text-xl font-semibold">Nagot gick fel</h2>
|
||||
<p className="text-muted-foreground text-sm max-w-md text-center">
|
||||
Ett oväntat fel uppstod. Försök igen eller kontakta support om problemet
|
||||
kvarstår.
|
||||
Ett oväntat fel uppstod. Försök igen eller{' '}
|
||||
<SupportLink variant="inline" subject="Oväntat fel">kontakta support</SupportLink>{' '}
|
||||
om problemet kvarstår.
|
||||
</p>
|
||||
<Button onClick={reset}>Försök igen</Button>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
import { Mail } from 'lucide-react'
|
||||
|
||||
interface GlossaryTerm {
|
||||
term: string
|
||||
@@ -462,6 +464,22 @@ export default function HelpPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Support section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Behöver du mer hjälp?</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Hittar du inte svaret? Kontakta oss så hjälper vi dig.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SupportLink variant="inline" subject="Fråga från hjälpsidan" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { CompanySettings } from '@/types'
|
||||
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
|
||||
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
import { SecuritySettings } from '@/components/settings/SecuritySettings'
|
||||
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
|
||||
@@ -761,6 +762,10 @@ export default function SettingsPage() {
|
||||
<p className="text-muted-foreground">
|
||||
Enligt bokföringslagen (BFL 7 kap. 2§) ska räkenskapsinformation bevaras i 7 år. Du ansvarar själv för att exportera och arkivera din bokföringsdata innan du raderar kontot.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Har du frågor?{' '}
|
||||
<SupportLink variant="inline" subject="Fråga om kontoradering" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import { SUPPORT_RECIPIENT_EMAIL } from '@/lib/support'
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
let body: { subject?: string; message?: string }
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const message = body.message?.trim()
|
||||
if (!message || message.length < 5) {
|
||||
return NextResponse.json({ error: 'Meddelandet måste vara minst 5 tecken' }, { status: 400 })
|
||||
}
|
||||
if (message.length > 5000) {
|
||||
return NextResponse.json({ error: 'Meddelandet får vara max 5000 tecken' }, { status: 400 })
|
||||
}
|
||||
|
||||
const subject = body.subject?.trim() || 'Supportärende'
|
||||
|
||||
const emailService = getEmailService()
|
||||
if (!emailService.isConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'E-posttjänsten är inte konfigurerad just nu. Försök igen senare.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const safeSubject = escapeHtml(subject)
|
||||
const safeMessage = escapeHtml(message).replace(/\n/g, '<br />')
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: SUPPORT_RECIPIENT_EMAIL,
|
||||
subject: `[gnubok support] ${subject}`,
|
||||
replyTo: user.email,
|
||||
html: `
|
||||
<p><strong>Från:</strong> ${escapeHtml(user.email || '')}</p>
|
||||
<p><strong>User ID:</strong> ${user.id}</p>
|
||||
<p><strong>Ämne:</strong> ${safeSubject}</p>
|
||||
<hr />
|
||||
<p>${safeMessage}</p>
|
||||
`,
|
||||
text: `Från: ${user.email}\nUser ID: ${user.id}\nÄmne: ${subject}\n\n${message}`,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte skicka meddelandet. Försök igen.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { sent: true } })
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ClipboardCheck,
|
||||
} from 'lucide-react'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
interface ExtensionNavItem {
|
||||
@@ -304,8 +305,11 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Logout button */}
|
||||
<div className="flex-shrink-0 px-3 py-3 border-t border-border/30">
|
||||
{/* Support + Logout */}
|
||||
<div className="flex-shrink-0 px-3 py-3 border-t border-border/30 space-y-1">
|
||||
<div className="px-3 py-1.5">
|
||||
<SupportLink variant="muted" />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-muted-foreground hover:text-foreground text-[13px] h-9 px-3"
|
||||
@@ -531,8 +535,11 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="px-2 py-2 mt-1 border-t border-border/30">
|
||||
{/* Support + Logout */}
|
||||
<div className="px-2 py-2 mt-1 border-t border-border/30 space-y-1">
|
||||
<div className="px-3 py-2">
|
||||
<SupportLink variant="muted" />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-muted-foreground active:text-foreground text-sm h-11 px-3"
|
||||
|
||||
@@ -138,8 +138,8 @@ export default function AccountMappingStep({
|
||||
<CardHeader>
|
||||
<CardTitle>Kontomappning</CardTitle>
|
||||
<CardDescription>
|
||||
Granska och justera mappningarna mellan importerade konton och din kontoplan.
|
||||
Alla konton måste vara mappade innan importen kan genomföras.
|
||||
Varje konto i SIE-filen kopplas till ett konto i din kontoplan.
|
||||
De flesta matchas automatiskt — granska de osäkra nedan.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FileText,
|
||||
ExternalLink,
|
||||
RotateCcw,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
import type { ImportResult } from '@/lib/import/types'
|
||||
|
||||
@@ -21,7 +22,12 @@ interface ImportResultStepProps {
|
||||
|
||||
export default function ImportResultStep({ result, onNewImport }: ImportResultStepProps) {
|
||||
const hasErrors = result.errors.length > 0
|
||||
const hasWarnings = result.warnings.length > 0
|
||||
const skipped = result.details?.skippedVouchers
|
||||
|
||||
// Filter out raw "hoppades över" warnings when we have structured data
|
||||
const otherWarnings = skipped && skipped.total > 0
|
||||
? result.warnings.filter((w) => !w.includes('hoppades över'))
|
||||
: result.warnings
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -116,18 +122,66 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{hasWarnings && (
|
||||
{/* Skipped vouchers — structured breakdown */}
|
||||
{skipped && skipped.total > 0 && (
|
||||
<Card className="border-muted-foreground/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-muted-foreground">
|
||||
<Info className="h-5 w-5" />
|
||||
Hoppade över {skipped.total} verifikationer
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{skipped.empty > 0 && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">{skipped.empty} tomma verifikationer</p>
|
||||
<p className="text-muted-foreground">
|
||||
Platshållare utan bokföringsrader — vanligt i Fortnox och Visma. Påverkar inte din bokföring.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{skipped.unbalanced > 0 && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">{skipped.unbalanced} obalanserade verifikationer</p>
|
||||
<p className="text-muted-foreground">
|
||||
Debet och kredit stämmer inte överens i källsystemet. Saldon har justerats automatiskt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{skipped.singleLine > 0 && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">{skipped.singleLine} enradsverifikationer</p>
|
||||
<p className="text-muted-foreground">
|
||||
Verifikationer med bara en rad (t.ex. periodiseringar). Kräver minst två rader för dubbelbokning.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{skipped.unmapped > 0 && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">{skipped.unmapped} verifikationer med ej kopplade konton</p>
|
||||
<p className="text-muted-foreground">
|
||||
Innehåller konton som inte kunde kopplas till din kontoplan.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Other warnings (filtered) */}
|
||||
{otherWarnings.length > 0 && (
|
||||
<Card className="border-warning/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-warning">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Varningar ({result.warnings.length})
|
||||
Varningar ({otherWarnings.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{result.warnings.map((warning, i) => (
|
||||
{otherWarnings.map((warning, i) => (
|
||||
<div key={i} className="text-sm flex gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
|
||||
<span>{warning}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
FileText,
|
||||
Database,
|
||||
} from 'lucide-react'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import type { ImportPreview, AccountMapping } from '@/lib/import/types'
|
||||
|
||||
interface ImportReviewStepProps {
|
||||
@@ -52,6 +53,23 @@ export default function ImportReviewStep({
|
||||
importTransactions: true,
|
||||
voucherSeries: 'B',
|
||||
})
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Block browser close/refresh during import
|
||||
useUnsavedChanges(isLoading)
|
||||
|
||||
// Elapsed time counter during import
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setElapsed(0)
|
||||
intervalRef.current = setInterval(() => setElapsed((s) => s + 1), 1000)
|
||||
} else {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
setElapsed(0)
|
||||
}
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
|
||||
}, [isLoading])
|
||||
|
||||
const handleExecute = () => {
|
||||
onExecute(options)
|
||||
@@ -69,6 +87,33 @@ export default function ImportReviewStep({
|
||||
const hasOpeningBalances = preview.openingBalanceTotal > 0
|
||||
const hasTransactions = preview.voucherCount > 0
|
||||
|
||||
// Full-screen loading takeover during import execution
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-8 pb-8">
|
||||
<div className="flex flex-col items-center text-center space-y-6">
|
||||
<Loader2 className="h-10 w-10 text-primary animate-spin" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-lg">Importerar bokföring...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{preview.voucherCount} verifikationer bearbetas
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-2xl font-display font-medium tabular-nums text-muted-foreground">
|
||||
{elapsed}s
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
Stäng inte sidan. Importen kan ta upp till några minuter beroende på antalet verifikationer.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
@@ -247,21 +292,12 @@ export default function ImportReviewStep({
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
|
||||
<Button variant="outline" className="min-h-11" onClick={onBack} disabled={isLoading}>
|
||||
<Button variant="outline" className="min-h-11" onClick={onBack}>
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button className="min-h-11" onClick={handleExecute} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Importerar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Starta import
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
<Button className="min-h-11" onClick={handleExecute}>
|
||||
Starta import
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,7 +238,7 @@ export default function SIEPreviewStep({
|
||||
)}
|
||||
|
||||
{/* Create missing accounts */}
|
||||
{missingAccounts.length > 0 && (
|
||||
{missingAccounts.length > 0 ? (
|
||||
<Card className="border-primary/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -246,8 +246,8 @@ export default function SIEPreviewStep({
|
||||
Skapa saknade konton
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{missingAccounts.length} konton från SIE-filen finns inte i din kontoplan.
|
||||
Du kan skapa dem automatiskt för att behålla samma kontostruktur som i ditt gamla system.
|
||||
{missingAccounts.length} konton från SIE-filen finns inte i din kontoplan ännu.
|
||||
Klicka nedan för att skapa dem — de kopplas sedan automatiskt i nästa steg.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -279,6 +279,11 @@ export default function SIEPreviewStep({
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : preview.mappingStatus.mapped === preview.mappingStatus.total && preview.mappingStatus.total > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-success/50 bg-success/5 px-4 py-3 text-sm">
|
||||
<CheckCircle className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>Alla konton skapade och automatiskt kopplade</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issues */}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Upload, FileText, AlertCircle, CheckCircle } from 'lucide-react'
|
||||
import { Upload, FileText, AlertCircle, CheckCircle, Loader2 } from 'lucide-react'
|
||||
|
||||
const LOADING_PHASES = [
|
||||
{ message: 'Läser fil...', progress: 10 },
|
||||
{ message: 'Identifierar teckenkodning...', progress: 30 },
|
||||
{ message: 'Tolkar SIE-data...', progress: 55 },
|
||||
{ message: 'Matchar konton mot BAS-kontoplanen...', progress: 85 },
|
||||
] as const
|
||||
|
||||
interface SIEUploadStepProps {
|
||||
onFileSelect: (file: File) => void
|
||||
@@ -14,6 +21,23 @@ interface SIEUploadStepProps {
|
||||
export default function SIEUploadStep({ onFileSelect, isLoading, error }: SIEUploadStepProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [loadingPhase, setLoadingPhase] = useState(0)
|
||||
|
||||
// Cycle through loading phases on timers
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setLoadingPhase(0)
|
||||
return
|
||||
}
|
||||
|
||||
const timers = [
|
||||
setTimeout(() => setLoadingPhase(1), 2000),
|
||||
setTimeout(() => setLoadingPhase(2), 4000),
|
||||
setTimeout(() => setLoadingPhase(3), 7000),
|
||||
]
|
||||
|
||||
return () => timers.forEach(clearTimeout)
|
||||
}, [isLoading])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -47,6 +71,33 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error }: SIEUpl
|
||||
}
|
||||
}, [onFileSelect])
|
||||
|
||||
const phase = LOADING_PHASES[loadingPhase]
|
||||
|
||||
// Full-card takeover while analyzing the file
|
||||
if (isLoading && selectedFile) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-8 pb-8">
|
||||
<div className="flex flex-col items-center text-center space-y-6">
|
||||
<Loader2 className="h-10 w-10 text-primary animate-spin" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-lg">{phase.message}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
|
||||
</p>
|
||||
</div>
|
||||
<Progress value={phase.progress} className="w-64 mx-auto transition-all duration-1000" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lämna inte sidan förrän analysen är klar
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@@ -64,10 +115,9 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error }: SIEUpl
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg p-8 text-center transition-colors
|
||||
relative border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer hover:border-primary/50
|
||||
${isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
|
||||
${error ? 'border-destructive bg-destructive/5' : ''}
|
||||
${isLoading ? 'pointer-events-none opacity-50' : 'cursor-pointer hover:border-primary/50'}
|
||||
`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -83,13 +133,7 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error }: SIEUpl
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<FileText className="mx-auto h-12 w-12 text-muted-foreground animate-pulse" />
|
||||
<p className="text-muted-foreground">Analyserar fil...</p>
|
||||
<Progress value={33} className="w-48 mx-auto" />
|
||||
</div>
|
||||
) : selectedFile ? (
|
||||
{selectedFile ? (
|
||||
<div className="space-y-4">
|
||||
<CheckCircle className="mx-auto h-12 w-12 text-success" />
|
||||
<div>
|
||||
|
||||
@@ -254,7 +254,7 @@ export function SecuritySettings() {
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push('/mfa/enroll?returnTo=/settings?tab=security')}
|
||||
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings?tab=security')}`)}
|
||||
>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
Aktivera 2FA
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Plus,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon
|
||||
@@ -25,6 +26,7 @@ interface EmptyStateProps {
|
||||
onAction?: () => void
|
||||
secondaryActionLabel?: string
|
||||
secondaryActionHref?: string
|
||||
supportHint?: boolean
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
@@ -41,6 +43,7 @@ export function EmptyState({
|
||||
onAction,
|
||||
secondaryActionLabel,
|
||||
secondaryActionHref,
|
||||
supportHint,
|
||||
className,
|
||||
children,
|
||||
}: EmptyStateProps) {
|
||||
@@ -56,6 +59,14 @@ export function EmptyState({
|
||||
<h3 className="text-lg font-medium mb-2">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm mb-6 text-balance">{description}</p>
|
||||
|
||||
{supportHint && (
|
||||
<div className="mb-6">
|
||||
<SupportLink variant="muted" subject="Behöver hjälp att komma igång">
|
||||
Behöver du hjälp? Kontakta support
|
||||
</SupportLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(actionLabel || children) && (
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3">
|
||||
{actionHref && actionLabel && (
|
||||
@@ -121,6 +132,7 @@ export function EmptyTransactions() {
|
||||
description="Importera kontoutdrag från din bank för att automatiskt bokföra och få koll på ekonomin."
|
||||
actionLabel="Importera transaktioner"
|
||||
actionHref="/import"
|
||||
supportHint
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -155,6 +167,7 @@ export function NoBankConnected() {
|
||||
description="Importera kontoutdrag från din bank för att automatiskt bokföra och få bättre koll på ekonomin."
|
||||
actionLabel="Importera transaktioner"
|
||||
actionHref="/import"
|
||||
supportHint
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Mail, Loader2, Send } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
|
||||
interface SupportLinkProps {
|
||||
variant?: 'inline' | 'muted'
|
||||
subject?: string
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SupportLink({
|
||||
variant = 'inline',
|
||||
subject,
|
||||
children,
|
||||
className,
|
||||
}: SupportLinkProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (message.trim().length < 5) return
|
||||
|
||||
setIsSending(true)
|
||||
try {
|
||||
const res = await fetch('/api/support/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subject, message: message.trim() }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || 'Kunde inte skicka meddelandet')
|
||||
}
|
||||
|
||||
setSent(true)
|
||||
setTimeout(() => {
|
||||
setOpen(false)
|
||||
setSent(false)
|
||||
setMessage('')
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte skicka',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
setOpen(next)
|
||||
if (!next) {
|
||||
setSent(false)
|
||||
setMessage('')
|
||||
}
|
||||
}
|
||||
|
||||
const trigger =
|
||||
variant === 'muted' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Mail className="h-3 w-3" />
|
||||
{children ?? 'Kontakta support'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-primary hover:text-primary/80 underline-offset-4 hover:underline transition-colors text-sm cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children ?? 'Kontakta support'}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kontakta support</DialogTitle>
|
||||
<DialogDescription>
|
||||
Beskriv ditt ärende så återkommer vi så snart vi kan.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{sent ? (
|
||||
<div className="flex flex-col items-center py-6 gap-3">
|
||||
<div className="p-3 rounded-full bg-success/10">
|
||||
<Send className="h-6 w-6 text-success" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">Tack! Vi har mottagit ditt meddelande.</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Beskriv vad du behöver hjälp med..."
|
||||
className="min-h-[120px] resize-none"
|
||||
maxLength={5000}
|
||||
disabled={isSending}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{message.length}/5000 tecken
|
||||
</p>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSending}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSending || message.trim().length < 5}
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Skickar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Skicka
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { makeTransaction, makeInvoice, makeCustomer, makeFiscalPeriod } from '@/tests/helpers'
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Support recipient — server-side only.
|
||||
* Used by the /api/support/contact route. Never exposed to the client.
|
||||
*/
|
||||
export const SUPPORT_RECIPIENT_EMAIL = process.env.SUPPORT_RECIPIENT_EMAIL || 'support@gnubok.se'
|
||||
Reference in New Issue
Block a user