diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx index f85883a9..a6cece36 100644 --- a/app/(dashboard)/customers/page.tsx +++ b/app/(dashboard)/customers/page.tsx @@ -1,19 +1,30 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo, useCallback, Suspense } from 'react' import { useLocale, useTranslations } from 'next-intl' +import { useSearchParams, useRouter, usePathname } from 'next/navigation' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' -import { Plus, Search, Users, Lock } from 'lucide-react' +import { Plus, Search, Users, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react' import CustomerForm from '@/components/customers/CustomerForm' import { EmptyCustomers, EmptyState } from '@/components/ui/empty-state' import { PageHeader } from '@/components/ui/page-header' +import { formatDate } from '@/lib/utils' import Link from 'next/link' import { useCompany } from '@/contexts/CompanyContext' import { useCanWrite } from '@/lib/hooks/use-can-write' @@ -26,6 +37,18 @@ const CUSTOMER_TYPE_LABEL_KEYS: Record = { non_eu_business: 'type_non_eu_business', } +type SortColumn = 'name' | 'customer_type' | 'identifier' | 'email' | 'city' | 'created_at' +type SortDir = 'asc' | 'desc' + +const SORTABLE_COLUMNS: ReadonlyArray = [ + 'name', + 'customer_type', + 'identifier', + 'email', + 'city', + 'created_at', +] + function getInitials(name: string): string { return name .split(' ') @@ -35,7 +58,15 @@ function getInitials(name: string): string { .toUpperCase() } -export default function CustomersPage() { +function getIdentifier(customer: Customer): string { + return customer.org_number || customer.personal_number || '' +} + +function compareStrings(a: string, b: string): number { + return a.localeCompare(b, 'sv', { sensitivity: 'base' }) +} + +function CustomersPageInner() { const { company } = useCompany() const { canWrite } = useCanWrite() const [customers, setCustomers] = useState([]) @@ -48,6 +79,31 @@ export default function CustomersPage() { const t = useTranslations('customers') const errorLocale = useLocale() as ErrorLocale + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const sortParam = searchParams.get('sort') + const dirParam = searchParams.get('dir') + const sortColumn: SortColumn = (SORTABLE_COLUMNS as ReadonlyArray).includes(sortParam ?? '') + ? (sortParam as SortColumn) + : 'name' + const sortDir: SortDir = dirParam === 'desc' ? 'desc' : 'asc' + + const updateSort = useCallback( + (column: SortColumn) => { + const params = new URLSearchParams(searchParams.toString()) + let nextDir: SortDir = 'asc' + if (column === sortColumn) { + nextDir = sortDir === 'asc' ? 'desc' : 'asc' + } + params.set('sort', column) + params.set('dir', nextDir) + router.replace(`${pathname}?${params.toString()}`, { scroll: false }) + }, + [searchParams, sortColumn, sortDir, router, pathname] + ) + async function fetchCustomers() { if (!company) return setIsLoading(true) @@ -71,6 +127,7 @@ export default function CustomersPage() { useEffect(() => { fetchCustomers() + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) async function handleCreateCustomer(data: CreateCustomerInput) { @@ -102,11 +159,82 @@ export default function CustomersPage() { setIsCreating(false) } - const filteredCustomers = customers.filter((customer) => - customer.name.toLowerCase().includes(searchTerm.toLowerCase()) || - customer.email?.toLowerCase().includes(searchTerm.toLowerCase()) || - customer.org_number?.includes(searchTerm) - ) + const filteredCustomers = useMemo(() => { + const term = searchTerm.trim().toLowerCase() + if (!term) return customers + return customers.filter((c) => { + return ( + c.name.toLowerCase().includes(term) || + c.email?.toLowerCase().includes(term) || + c.org_number?.includes(term) || + c.personal_number?.includes(term) || + c.city?.toLowerCase().includes(term) || + c.notes?.toLowerCase().includes(term) + ) + }) + }, [customers, searchTerm]) + + const sortedCustomers = useMemo(() => { + const arr = [...filteredCustomers] + arr.sort((a, b) => { + let av = '' + let bv = '' + switch (sortColumn) { + case 'name': + av = a.name || '' + bv = b.name || '' + break + case 'customer_type': + av = a.customer_type || '' + bv = b.customer_type || '' + break + case 'identifier': + av = getIdentifier(a) + bv = getIdentifier(b) + break + case 'email': + av = a.email || '' + bv = b.email || '' + break + case 'city': + av = a.city || '' + bv = b.city || '' + break + case 'created_at': + av = a.created_at || '' + bv = b.created_at || '' + break + } + const cmp = compareStrings(av, bv) + return sortDir === 'asc' ? cmp : -cmp + }) + return arr + }, [filteredCustomers, sortColumn, sortDir]) + + function SortableHeader({ + column, + label, + className, + }: { + column: SortColumn + label: string + className?: string + }) { + const isActive = sortColumn === column + const Icon = isActive ? (sortDir === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown + return ( + + + + ) + } return (
@@ -153,20 +281,31 @@ export default function CustomersPage() { {/* Customer list */} {isLoading ? ( -
- {[1, 2, 3].map((i) => ( - - -
-
- - -
- - - ))} -
- ) : filteredCustomers.length === 0 ? ( + <> + {/* Desktop skeleton */} + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + + {/* Mobile skeleton */} +
+ {[1, 2, 3].map((i) => ( + + + + + + + + + + ))} +
+ + ) : sortedCustomers.length === 0 ? ( {searchTerm ? ( @@ -181,17 +320,89 @@ export default function CustomersPage() { ) : ( -
- {filteredCustomers.map((customer) => ( + <> + {/* Desktop table */} + + + + + + + + + + + + + + + {sortedCustomers.map((customer) => { + const identifier = getIdentifier(customer) + return ( + router.push(`/customers/${customer.id}`)} + > + + e.stopPropagation()} + > + {customer.name} + + + + + {t(CUSTOMER_TYPE_LABEL_KEYS[customer.customer_type])} + + + +
+ {identifier || '—'} + {customer.org_number && customer.vat_number_validated && ( + + {t('verified')} + + )} +
+
+ + {customer.email || '—'} + + + {customer.city || '—'} + + + {formatDate(customer.created_at)} + +
+ ) + })} +
+
+
+
+ + {/* Mobile card list */} +
+ {sortedCustomers.map((customer) => ( - +
-
+
{getInitials(customer.name)}
- {customer.name} + + {customer.name} + {t(CUSTOMER_TYPE_LABEL_KEYS[customer.customer_type])} @@ -200,27 +411,38 @@ export default function CustomersPage() {
- {customer.email && ( -

{customer.email}

- )} - {customer.org_number && ( -
- {customer.org_number} - {customer.vat_number_validated && ( - {t('verified')} + {customer.email &&

{customer.email}

} + {getIdentifier(customer) && ( +
+ {getIdentifier(customer)} + {customer.org_number && customer.vat_number_validated && ( + + {t('verified')} + )}
)} {customer.city && ( -

{customer.city}, {customer.country}

+

+ {customer.city}, {customer.country} +

)}
- ))} -
+ ))} +
+ )}
) } + +export default function CustomersPage() { + return ( + + + + ) +} diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 93a6d59a..a0fa5c2c 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -1,14 +1,15 @@ 'use client' import { useState, useCallback, useEffect } from 'react' -import { useSearchParams } from 'next/navigation' +import { useSearchParams, useRouter } from 'next/navigation' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' -import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet } from 'lucide-react' +import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet, Download } from 'lucide-react' import { motion } from 'framer-motion' import { cn } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' @@ -70,6 +71,8 @@ import type { import type { BASAccount } from '@/types' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import dynamic from 'next/dynamic' +import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' +import CloudBackupCard from '@/extensions/general/cloud-backup/components/CloudBackupCard' const MigrationWizard = dynamic( () => import('@/components/extensions/general/ArcimMigrationWorkspace'), @@ -1728,9 +1731,13 @@ type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'csv_data' | 'migration' export default function ImportPage() { const { company } = useCompany() const [mode, setMode] = useState(null) + const [view, setView] = useState<'import' | 'export'>('import') const [userId, setUserId] = useState('') const [isSandbox, setIsSandbox] = useState(false) + const [exportPeriodId, setExportPeriodId] = useState(null) const t = useTranslations('import') + const router = useRouter() + const hasCloudBackup = ENABLED_EXTENSION_IDS.has('cloud-backup') // Fetch authenticated user ID and sandbox status useEffect(() => { @@ -1750,7 +1757,7 @@ export default function ImportPage() { }) }, []) - // Sync mode from URL search params (reacts to client-side navigation changes) + // Sync mode + view from URL search params (reacts to client-side navigation changes) const searchParams = useSearchParams() useEffect(() => { if (isSandbox) return @@ -1762,7 +1769,33 @@ export default function ImportPage() { setMode(modeParam as ImportMode) } } + const viewParam = searchParams.get('view') + if (viewParam === 'export' || viewParam === 'import') { + setView(viewParam) + } }, [isSandbox, searchParams]) + + // Hash-based deep links (#cloud-backup, #sie-export) → switch to export tab and scroll + useEffect(() => { + if (typeof window === 'undefined') return + const hash = window.location.hash + if (hash === '#cloud-backup' || hash === '#sie-export') { + setView('export') + setTimeout(() => { + document.querySelector(hash)?.scrollIntoView({ block: 'start', behavior: 'smooth' }) + }, 50) + } + }, []) + + const handleViewChange = (next: string) => { + if (next !== 'import' && next !== 'export') return + setView(next) + const params = new URLSearchParams(searchParams.toString()) + if (next === 'export') params.set('view', 'export') + else params.delete('view') + const qs = params.toString() + router.replace(qs ? `/import?${qs}` : '/import', { scroll: false }) + } // Extensions are active if compiled in — no runtime toggle check needed const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking') const hasMigrationExtension = ENABLED_EXTENSION_IDS.has('arcim-migration') @@ -1788,7 +1821,14 @@ export default function ImportPage() {
)} -
+ + + {t('tab_import')} + {t('tab_export')} + + + +
{/* 1. Koppla bank */} {hasBankingExtension && (
-
+
+ + + +
+ {/* SIE-export */} + + + + + {t('export_sie_title')} + + {t('export_sie_description')} + + + + + {!exportPeriodId && ( +

{t('export_sie_no_period')}

+ )} +
+
+ + {/* Molnsynkronisering (Google Drive) */} + {hasCloudBackup && ( +
+ +
+ )} +
+
+ )} diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 5efc0744..19d92d0b 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -107,18 +107,14 @@ export default function ReportsPage() { hideFuturePeriods onReady={() => setIsLoadingInit(false)} /> - {selectedPeriod && ( - - )}
+

+ {t('sie_moved_hint')}{' '} + + {t('sie_moved_link')} + + . +

{isLoadingInit ? (
diff --git a/app/(dashboard)/settings/backup/page.tsx b/app/(dashboard)/settings/backup/page.tsx index 983b79d9..722cf0ff 100644 --- a/app/(dashboard)/settings/backup/page.tsx +++ b/app/(dashboard)/settings/backup/page.tsx @@ -1,22 +1,9 @@ -import { getTranslations } from 'next-intl/server' -import { BackupDownloadForm } from '@/components/settings/BackupDownloadForm' -import { getBranding } from '@/lib/branding/service' +import { redirect } from 'next/navigation' -export default async function BackupSettingsPage() { - const t = await getTranslations('settings_backup') - const { appName } = getBranding() - return ( -
-
-

- {t('heading')} -

-

- {t('intro', { appName: appName.toLowerCase() })} -

-
- - -
- ) +// Säkerhetskopia + Google Drive-molnsynkronisering ligger numera under +// /import (Importera/Exportera). Den här sidan finns kvar endast som en +// permanent omdirigering så att gamla bokmärken och cloud-backup-extensionens +// `settingsPanel.path` fortfarande tar användaren till rätt plats. +export default function BackupSettingsPage() { + redirect('/import?view=export#cloud-backup') } diff --git a/app/(dashboard)/settings/invoicing/page.tsx b/app/(dashboard)/settings/invoicing/page.tsx index 4602da3b..13db31ff 100644 --- a/app/(dashboard)/settings/invoicing/page.tsx +++ b/app/(dashboard)/settings/invoicing/page.tsx @@ -3,6 +3,7 @@ import { useTranslations } from 'next-intl' import { BankDetailsForm, validateBankFields } from '@/components/settings/BankDetailsForm' import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm' +import { InvoicePreviewCard } from '@/components/settings/InvoicePreviewCard' import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings' import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' @@ -49,17 +50,25 @@ export default function InvoicingSettingsPage() { } return ( -
- - -
- -
-
+
+
+ + +
+ +
+
- {/* PDF settings — saves individually via toggle switches */} -
- + {/* PDF settings — saves individually via toggle switches */} +
+ +
+
+ +
+
+ +
) diff --git a/app/api/documents/[id]/inline/route.ts b/app/api/documents/[id]/inline/route.ts new file mode 100644 index 00000000..c66124b8 --- /dev/null +++ b/app/api/documents/[id]/inline/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' +import { createServiceClient } from '@/lib/supabase/server' + +/** + * GET /api/documents/:id/inline + * + * Same-origin proxy that streams a document attachment with + * `Content-Disposition: inline`, allowing it to render inside + * an