From e8743e6e034722990947ef961a2eea2876b0a664 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 19 Feb 2026 14:32:54 +0100 Subject: [PATCH] New base --- .claude/settings.local.json | 3 +- app/(dashboard)/deductions/page.tsx | 379 -------------- app/(dashboard)/invoices/page.tsx | 14 +- app/(dashboard)/layout.tsx | 4 +- .../modules/[sector]/[module]/page.tsx | 145 ------ app/(dashboard)/modules/[sector]/page.tsx | 151 ------ app/(dashboard)/modules/page.tsx | 50 -- app/(dashboard)/page.tsx | 8 - app/(dashboard)/receipts/page.tsx | 345 +------------ app/(dashboard)/receipts/scan/page.tsx | 158 +----- app/(dashboard)/reports/page.tsx | 328 ++---------- app/(dashboard)/settings/page.tsx | 2 +- app/api/{chat => extensions/ai-chat}/route.ts | 4 +- .../ai-chat}/sessions/[id]/route.ts | 0 .../ai-chat}/sessions/route.ts | 0 .../ai-chat}/stream/route.ts | 4 +- .../ne-bilaga}/route.ts | 6 +- .../push-notifications}/cron/route.ts | 8 +- .../push-notifications/settings/route.ts | 61 +++ .../push-notifications}/subscribe/route.ts | 8 +- .../receipt-ocr}/[id]/confirm/route.ts | 0 .../receipt-ocr}/[id]/match/route.ts | 2 +- .../receipt-ocr}/[id]/route.ts | 0 .../receipt-ocr}/queue/route.ts | 0 .../receipt-ocr}/route.ts | 0 .../receipt-ocr}/upload/route.ts | 4 +- .../extensions/sru-export/coverage/route.ts | 29 ++ app/api/extensions/sru-export/route.ts | 150 ++++++ app/globals.css | 123 +++-- app/layout.tsx | 2 +- app/page.tsx | 8 - components/bookkeeping/ChartOfAccounts.tsx | 43 ++ components/bookkeeping/JournalEntryList.tsx | 2 +- components/calendar/CalendarDayView.tsx | 22 +- components/calendar/CalendarWeekView.tsx | 2 +- components/calendar/DeadlineCard.tsx | 2 +- components/calendar/PaymentSummaryCard.tsx | 16 +- components/calendar/TaxTodoWidget.tsx | 8 +- components/chat/useChatStream.ts | 4 +- components/dashboard/DashboardContent.tsx | 318 ++++++------ components/dashboard/DashboardNav.tsx | 170 ++---- components/dashboard/FSkattWarningCard.tsx | 6 +- components/modules/ModuleToggle.tsx | 87 ---- .../settings/SchablonavdragSettings.tsx | 204 -------- components/transactions/MileageEntry.tsx | 242 --------- .../transactions/SwipeCategorizationView.tsx | 10 +- components/ui/card.tsx | 2 +- components/ui/empty-state.tsx | 9 +- components/ui/page-header.tsx | 6 +- dev_docs/ARCHITECTURE.md | 15 + .../base_architecture/NE-BILAGA-EXTENSION.md | 260 ++++++++++ .../PUSH-NOTIFICATIONS-EXTENSION.md | 312 ++++++++++++ .../base_architecture/SRU-EXPORT-EXTENSION.md | 257 ++++++++++ .../ai-chat}/chatbot/chain.ts | 0 .../ai-chat}/chatbot/config.ts | 0 .../ai-chat}/chatbot/embeddings.ts | 0 .../ai-chat}/chatbot/prompts.ts | 0 .../ai-chat}/chatbot/retriever.ts | 0 extensions/ai-chat/index.ts | 22 + .../ai-chat}/ingestion/ingest.ts | 0 extensions/ne-bilaga/NEDeclarationView.tsx | 296 +++++++++++ extensions/ne-bilaga/index.ts | 23 + .../ne-bilaga/ne-engine.ts | 0 extensions/ne-bilaga/types.ts | 80 +++ .../NotificationSettings.tsx | 6 +- .../push-notifications}/PushPrompt.tsx | 4 +- extensions/push-notifications/index.ts | 236 +++++++++ .../notification-scheduler.ts | 210 ++++++++ .../push-notifications/notification-sender.ts | 298 +++++++++++ .../push-notifications/payload-builders.ts | 190 +++++++ extensions/push-notifications/types.ts | 48 ++ .../receipt-ocr/__tests__/index.test.ts | 8 +- .../receipt-ocr/components}/ReceiptCamera.tsx | 2 +- .../components}/ReceiptDashboard.tsx | 24 +- .../components}/ReceiptLineItemRow.tsx | 2 +- .../components}/ReceiptReviewView.tsx | 10 +- .../components}/TransactionMatcher.tsx | 2 +- .../receipt-ocr/components}/index.ts | 0 extensions/receipt-ocr/index.ts | 6 +- .../__tests__/receipt-categorizer.test.ts | 0 .../lib}/__tests__/receipt-matcher.test.ts | 0 .../receipt-ocr/lib}/receipt-analyzer.ts | 0 .../receipt-ocr/lib}/receipt-categorizer.ts | 0 .../receipt-ocr/lib}/receipt-matcher.ts | 0 .../receipt-ocr/lib}/receipt-utils.ts | 0 extensions/receipt-ocr/pages/ReceiptsPage.tsx | 344 +++++++++++++ .../pages/scan/ScanReceiptPage.tsx | 157 ++++++ extensions/receipt-ocr/types.ts | 171 +++++++ extensions/sru-export/SRUExportView.tsx | 243 +++++++++ extensions/sru-export/index.ts | 23 + .../sru-export/lib}/sru-generator.ts | 0 extensions/sru-export/sru-engine.ts | 151 ++++++ extensions/sru-export/sru-generator.ts | 140 +++++ extensions/sru-export/types.ts | 37 ++ lib/extensions/loader.ts | 8 + lib/modules-data.ts | 482 ------------------ lib/push/notification-scheduler.ts | 377 -------------- lib/push/web-push.ts | 203 -------- lib/tax/calculator.ts | 18 +- lib/tax/schablonavdrag.ts | 199 -------- .../migrations/20240101000021_sru_codes.sql | 340 ++++++++++++ .../20240101000022_drop_module_toggles.sql | 2 + types/index.ts | 366 +------------ vercel.json | 2 +- 104 files changed, 4551 insertions(+), 4172 deletions(-) delete mode 100644 app/(dashboard)/deductions/page.tsx delete mode 100644 app/(dashboard)/modules/[sector]/[module]/page.tsx delete mode 100644 app/(dashboard)/modules/[sector]/page.tsx delete mode 100644 app/(dashboard)/modules/page.tsx rename app/api/{chat => extensions/ai-chat}/route.ts (96%) rename app/api/{chat => extensions/ai-chat}/sessions/[id]/route.ts (100%) rename app/api/{chat => extensions/ai-chat}/sessions/route.ts (100%) rename app/api/{chat => extensions/ai-chat}/stream/route.ts (97%) rename app/api/{reports/ne-declaration => extensions/ne-bilaga}/route.ts (92%) rename app/api/{push => extensions/push-notifications}/cron/route.ts (88%) create mode 100644 app/api/extensions/push-notifications/settings/route.ts rename app/api/{push => extensions/push-notifications}/subscribe/route.ts (92%) rename app/api/{receipts => extensions/receipt-ocr}/[id]/confirm/route.ts (100%) rename app/api/{receipts => extensions/receipt-ocr}/[id]/match/route.ts (98%) rename app/api/{receipts => extensions/receipt-ocr}/[id]/route.ts (100%) rename app/api/{receipts => extensions/receipt-ocr}/queue/route.ts (100%) rename app/api/{receipts => extensions/receipt-ocr}/route.ts (100%) rename app/api/{receipts => extensions/receipt-ocr}/upload/route.ts (97%) create mode 100644 app/api/extensions/sru-export/coverage/route.ts create mode 100644 app/api/extensions/sru-export/route.ts delete mode 100644 components/modules/ModuleToggle.tsx delete mode 100644 components/settings/SchablonavdragSettings.tsx delete mode 100644 components/transactions/MileageEntry.tsx create mode 100644 dev_docs/base_architecture/NE-BILAGA-EXTENSION.md create mode 100644 dev_docs/base_architecture/PUSH-NOTIFICATIONS-EXTENSION.md create mode 100644 dev_docs/base_architecture/SRU-EXPORT-EXTENSION.md rename {lib/ai => extensions/ai-chat}/chatbot/chain.ts (100%) rename {lib/ai => extensions/ai-chat}/chatbot/config.ts (100%) rename {lib/ai => extensions/ai-chat}/chatbot/embeddings.ts (100%) rename {lib/ai => extensions/ai-chat}/chatbot/prompts.ts (100%) rename {lib/ai => extensions/ai-chat}/chatbot/retriever.ts (100%) create mode 100644 extensions/ai-chat/index.ts rename {lib/ai => extensions/ai-chat}/ingestion/ingest.ts (100%) create mode 100644 extensions/ne-bilaga/NEDeclarationView.tsx create mode 100644 extensions/ne-bilaga/index.ts rename lib/reports/ne-declaration.ts => extensions/ne-bilaga/ne-engine.ts (100%) create mode 100644 extensions/ne-bilaga/types.ts rename {components/settings => extensions/push-notifications}/NotificationSettings.tsx (97%) rename {components/push => extensions/push-notifications}/PushPrompt.tsx (97%) create mode 100644 extensions/push-notifications/index.ts create mode 100644 extensions/push-notifications/notification-scheduler.ts create mode 100644 extensions/push-notifications/notification-sender.ts create mode 100644 extensions/push-notifications/payload-builders.ts create mode 100644 extensions/push-notifications/types.ts rename {components/receipts => extensions/receipt-ocr/components}/ReceiptCamera.tsx (99%) rename {components/receipts => extensions/receipt-ocr/components}/ReceiptDashboard.tsx (83%) rename {components/receipts => extensions/receipt-ocr/components}/ReceiptLineItemRow.tsx (97%) rename {components/receipts => extensions/receipt-ocr/components}/ReceiptReviewView.tsx (97%) rename {components/receipts => extensions/receipt-ocr/components}/TransactionMatcher.tsx (98%) rename {components/receipts => extensions/receipt-ocr/components}/index.ts (100%) rename {lib/receipts => extensions/receipt-ocr/lib}/__tests__/receipt-categorizer.test.ts (100%) rename {lib/receipts => extensions/receipt-ocr/lib}/__tests__/receipt-matcher.test.ts (100%) rename {lib/receipts => extensions/receipt-ocr/lib}/receipt-analyzer.ts (100%) rename {lib/receipts => extensions/receipt-ocr/lib}/receipt-categorizer.ts (100%) rename {lib/receipts => extensions/receipt-ocr/lib}/receipt-matcher.ts (100%) rename {lib/receipts => extensions/receipt-ocr/lib}/receipt-utils.ts (100%) create mode 100644 extensions/receipt-ocr/pages/ReceiptsPage.tsx create mode 100644 extensions/receipt-ocr/pages/scan/ScanReceiptPage.tsx create mode 100644 extensions/receipt-ocr/types.ts create mode 100644 extensions/sru-export/SRUExportView.tsx create mode 100644 extensions/sru-export/index.ts rename {lib/reports => extensions/sru-export/lib}/sru-generator.ts (100%) create mode 100644 extensions/sru-export/sru-engine.ts create mode 100644 extensions/sru-export/sru-generator.ts create mode 100644 extensions/sru-export/types.ts delete mode 100644 lib/modules-data.ts delete mode 100644 lib/push/notification-scheduler.ts delete mode 100644 lib/push/web-push.ts delete mode 100644 lib/tax/schablonavdrag.ts create mode 100644 supabase/migrations/20240101000021_sru_codes.sql create mode 100644 supabase/migrations/20240101000022_drop_module_toggles.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f464510c..a6d2fb8b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "mcp__ide__getDiagnostics", "Bash(grep:*)", "Bash(xargs:*)", - "Bash(ls:*)" + "Bash(ls:*)", + "Bash(find:*)" ] } } diff --git a/app/(dashboard)/deductions/page.tsx b/app/(dashboard)/deductions/page.tsx deleted file mode 100644 index 84b25be3..00000000 --- a/app/(dashboard)/deductions/page.tsx +++ /dev/null @@ -1,379 +0,0 @@ -'use client' - -import { useState, useEffect } from 'react' -import { useRouter } from 'next/navigation' -import { createClient } from '@/lib/supabase/client' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { useToast } from '@/components/ui/use-toast' -import { formatCurrency, formatDate } from '@/lib/utils' -import { - getSchablonavdragSummary, - groupMileageEntriesByMonth, - formatMonthKey, - SCHABLONAVDRAG_RATES, -} from '@/lib/tax/schablonavdrag' -import SchablonavdragSettings from '@/components/settings/SchablonavdragSettings' -import MileageEntry from '@/components/transactions/MileageEntry' -import { - Loader2, - Home, - Car, - Plus, - Trash2, - Calendar, - MapPin, - ChevronDown, - ChevronUp, -} from 'lucide-react' -import type { - CompanySettings, - SchablonavdragSettings as SchablonavdragSettingsType, - MileageEntry as MileageEntryType, - CreateMileageEntryInput, -} from '@/types' - -export default function DeductionsPage() { - const router = useRouter() - const { toast } = useToast() - const supabase = createClient() - - const [isLoading, setIsLoading] = useState(true) - const [settings, setSettings] = useState(null) - const [mileageEntries, setMileageEntries] = useState([]) - const [showAddForm, setShowAddForm] = useState(false) - const [expandedMonths, setExpandedMonths] = useState>(new Set()) - - useEffect(() => { - fetchData() - }, []) - - async function fetchData() { - setIsLoading(true) - - const { - data: { user }, - } = await supabase.auth.getUser() - if (!user) { - router.push('/login') - return - } - - // Fetch settings - const { data: settingsData } = await supabase - .from('company_settings') - .select('*') - .eq('user_id', user.id) - .single() - - setSettings(settingsData) - - // Fetch mileage entries for current year - const startOfYear = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0] - const { data: entries } = await supabase - .from('mileage_entries') - .select('*') - .eq('user_id', user.id) - .gte('date', startOfYear) - .order('date', { ascending: false }) - - setMileageEntries(entries || []) - setIsLoading(false) - } - - async function handleSaveSettings(newSettings: SchablonavdragSettingsType) { - if (!settings) return - - const { error } = await supabase - .from('company_settings') - .update({ schablonavdrag_settings: newSettings }) - .eq('id', settings.id) - - if (error) { - throw error - } - - setSettings({ - ...settings, - schablonavdrag_settings: newSettings, - } as CompanySettings & { schablonavdrag_settings: SchablonavdragSettingsType }) - } - - async function handleAddMileageEntry(entry: CreateMileageEntryInput) { - const { - data: { user }, - } = await supabase.auth.getUser() - if (!user) return - - const { data, error } = await supabase - .from('mileage_entries') - .insert({ - user_id: user.id, - date: entry.date, - distance_km: entry.distance_km, - purpose: entry.purpose, - from_location: entry.from_location || null, - to_location: entry.to_location || null, - }) - .select() - .single() - - if (error) { - throw error - } - - setMileageEntries([data, ...mileageEntries]) - setShowAddForm(false) - } - - async function handleDeleteMileageEntry(id: string) { - const { error } = await supabase.from('mileage_entries').delete().eq('id', id) - - if (error) { - toast({ - title: 'Fel', - description: 'Kunde inte ta bort körningen', - variant: 'destructive', - }) - return - } - - setMileageEntries(mileageEntries.filter((e) => e.id !== id)) - toast({ - title: 'Borttaget', - description: 'Körningen har tagits bort', - }) - } - - function toggleMonth(monthKey: string) { - const newExpanded = new Set(expandedMonths) - if (newExpanded.has(monthKey)) { - newExpanded.delete(monthKey) - } else { - newExpanded.add(monthKey) - } - setExpandedMonths(newExpanded) - } - - if (isLoading) { - return ( -
- -
- ) - } - - // Get schablonavdrag settings - const schablonavdragSettings = ((settings as CompanySettings & { schablonavdrag_settings?: SchablonavdragSettingsType })?.schablonavdrag_settings) || { - hemmakontor_enabled: false, - bil_enabled: false, - } - - // Calculate summary - const currentYear = new Date().getFullYear() - const currentMonth = new Date().getMonth() + 1 - const summary = getSchablonavdragSummary( - schablonavdragSettings, - mileageEntries, - currentYear, - currentMonth - ) - - // Group mileage entries by month - const entriesByMonth = groupMileageEntriesByMonth(mileageEntries) - - return ( -
-
-

Schablonavdrag

-

- Hantera dina schablonmässiga avdrag för hemmakontor och bilkostnader -

-
- - {/* Summary cards */} -
- {/* Total deduction */} - - - - Totalt avdrag {currentYear} - - - -
- {formatCurrency(summary.total_deduction)} -
-
-
- - {/* Hemmakontor */} - - - - - Hemmakontor - - - -
- {summary.hemmakontor.enabled ? formatCurrency(summary.hemmakontor.deduction) : '-'} -
- {summary.hemmakontor.enabled && ( -

- {summary.hemmakontor.months_active} månader -

- )} -
-
- - {/* Bilkostnader */} - - - - - Bilkostnader - - - -
- {summary.mileage.enabled ? formatCurrency(summary.mileage.total_deduction) : '-'} -
- {summary.mileage.enabled && summary.mileage.total_km > 0 && ( -

- {summary.mileage.total_km.toFixed(0)} km loggade -

- )} -
-
-
- - - - Inställningar - - Körjournal - {schablonavdragSettings.bil_enabled && mileageEntries.length > 0 && ( - - {mileageEntries.length} - - )} - - - - {/* Settings tab */} - - - - - {/* Mileage log tab */} - - {/* Add new entry button/form */} - {showAddForm ? ( - setShowAddForm(false)} /> - ) : ( - - )} - - {/* Entries list grouped by month */} - {mileageEntries.length === 0 ? ( - - - -

Ingen körning loggad

-

- Börja logga dina tjänsteresor för att få milersättning -

-
-
- ) : ( -
- {Array.from(entriesByMonth.entries()).map(([monthKey, monthData]) => ( - - toggleMonth(monthKey)} - > -
-
- - {formatMonthKey(monthKey)} - - - {monthData.entries.length} körningar · {monthData.totalKm.toFixed(0)} km - -
-
- - {formatCurrency(monthData.totalDeduction)} - - {expandedMonths.has(monthKey) ? ( - - ) : ( - - )} -
-
-
- - {expandedMonths.has(monthKey) && ( - -
- {monthData.entries.map((entry) => ( -
-
-
- - {formatDate(entry.date)} - - {Number(entry.distance_km).toFixed(1)} km - -
-

{entry.purpose}

- {(entry.from_location || entry.to_location) && ( -
- - {entry.from_location && entry.to_location - ? `${entry.from_location} → ${entry.to_location}` - : entry.from_location || entry.to_location} -
- )} -
-
- - {formatCurrency(Number(entry.total_deduction))} - - -
-
- ))} -
-
- )} -
- ))} -
- )} -
-
-
- ) -} diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 514316cd..630187f6 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -119,8 +119,8 @@ export default function InvoicesPage() {
-
- +
+

Totalt antal

@@ -132,8 +132,8 @@ export default function InvoicesPage() {
-
- +
+

Obetalda

@@ -149,11 +149,11 @@ export default function InvoicesPage() {
- +
-
- +
+

Att få in

diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 519f96c1..099284d4 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -42,8 +42,8 @@ export default async function DashboardLayout({ companyName={settings.company_name || 'Min verksamhet'} entityType={entityType} /> -
-
+
+
{children}
diff --git a/app/(dashboard)/modules/[sector]/[module]/page.tsx b/app/(dashboard)/modules/[sector]/[module]/page.tsx deleted file mode 100644 index ee176642..00000000 --- a/app/(dashboard)/modules/[sector]/[module]/page.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import Link from 'next/link' -import { notFound } from 'next/navigation' -import { ArrowLeft, BookOpen, BarChart3, FileDown, Cog } from 'lucide-react' -import { getModuleBySlug, CATEGORY_LABELS, type ModuleCategory } from '@/lib/modules-data' -import { Badge } from '@/components/ui/badge' -import { ModuleToggle } from '@/components/modules/ModuleToggle' - -const CATEGORY_META: Record = { - bokforing: { - icon: BookOpen, - color: 'text-primary', - bgColor: 'bg-primary/8', - borderColor: 'border-primary/10', - }, - rapport: { - icon: BarChart3, - color: 'text-accent', - bgColor: 'bg-accent/8', - borderColor: 'border-accent/10', - }, - import: { - icon: FileDown, - color: 'text-success', - bgColor: 'bg-success/8', - borderColor: 'border-success/10', - }, - operativ: { - icon: Cog, - color: 'text-muted-foreground', - bgColor: 'bg-muted/60', - borderColor: 'border-border', - }, -} - -export default async function ModuleDetailPage({ - params, -}: { - params: Promise<{ sector: string; module: string }> -}) { - const { sector: sectorSlug, module: moduleSlug } = await params - const result = getModuleBySlug(sectorSlug, moduleSlug) - - if (!result) { - notFound() - } - - const { sector, module: mod } = result - const SectorIcon = sector.icon - const catMeta = CATEGORY_META[mod.cat] - const CatIcon = catMeta.icon - - return ( -
- {/* Navigation */} -
- - - {sector.name} - -
- - {/* Header */} -
-
-
- -
-
-

- {mod.name} -

-

- {mod.desc} -

-
-
- - {/* Badges */} -
- - {CATEGORY_LABELS[mod.cat]} - - - - {sector.name} - -
-
- - {/* Separator */} -
- - {/* Long description */} -
-
- {mod.longDesc.split('. Nyckelfunktioner: ').map((part, i) => { - if (i === 0) { - return ( -
-

- Beskrivning -

-

- {part}{mod.longDesc.includes('Nyckelfunktioner') ? '' : ''} -

-
- ) - } - return ( -
-

- Nyckelfunktioner -

-
    - {part.split('; ').map((feature, j) => ( -
  • - - {feature.replace(/,\s*$/, '')} -
  • - ))} -
-
- ) - })} -
-
- - {/* Toggle */} - -
- ) -} diff --git a/app/(dashboard)/modules/[sector]/page.tsx b/app/(dashboard)/modules/[sector]/page.tsx deleted file mode 100644 index 651737a7..00000000 --- a/app/(dashboard)/modules/[sector]/page.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import Link from 'next/link' -import { notFound } from 'next/navigation' -import { ArrowLeft, BookOpen, BarChart3, FileDown, Cog, ChevronRight } from 'lucide-react' -import { getSectorBySlug, CATEGORY_LABELS, type ModuleCategory } from '@/lib/modules-data' -import { Badge } from '@/components/ui/badge' - -const CATEGORY_ORDER: ModuleCategory[] = ['bokforing', 'rapport', 'import', 'operativ'] - -const CATEGORY_META: Record = { - bokforing: { - icon: BookOpen, - color: 'text-primary', - bgColor: 'bg-primary/8', - borderColor: 'border-primary/10', - }, - rapport: { - icon: BarChart3, - color: 'text-accent', - bgColor: 'bg-accent/8', - borderColor: 'border-accent/10', - }, - import: { - icon: FileDown, - color: 'text-success', - bgColor: 'bg-success/8', - borderColor: 'border-success/10', - }, - operativ: { - icon: Cog, - color: 'text-muted-foreground', - bgColor: 'bg-muted/60', - borderColor: 'border-border', - }, -} - -export default async function SectorPage({ - params, -}: { - params: Promise<{ sector: string }> -}) { - const { sector: slug } = await params - const sector = getSectorBySlug(slug) - - if (!sector) { - notFound() - } - - const Icon = sector.icon - const grouped = CATEGORY_ORDER.map((cat) => ({ - cat, - label: CATEGORY_LABELS[cat], - meta: CATEGORY_META[cat], - modules: sector.modules.filter((m) => m.cat === cat), - })).filter((g) => g.modules.length > 0) - - const totalModules = sector.modules.length - - return ( -
- {/* Navigation */} -
- - - Alla branscher - -
- - {/* Hero header */} -
-
-
- -
-
-

- {sector.name} -

-

- {sector.description} -

-
- - {totalModules} moduler - - - {grouped.length} kategorier - -
-
-
-
- - {/* Separator */} -
- - {/* Category sections */} -
- {grouped.map((group) => { - const CatIcon = group.meta.icon - return ( -
- {/* Category header */} -
-
- -
-
-

- {group.label} -

- - {group.modules.length} - -
-
- - {/* Module cards */} -
- {group.modules.map((mod) => ( - -
-

- {mod.name} -

-

- {mod.desc} -

-
- - - ))} -
-
- ) - })} -
-
- ) -} diff --git a/app/(dashboard)/modules/page.tsx b/app/(dashboard)/modules/page.tsx deleted file mode 100644 index ed933253..00000000 --- a/app/(dashboard)/modules/page.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import Link from 'next/link' -import { Layers } from 'lucide-react' -import { sectors } from '@/lib/modules-data' - -export default function ModulesPage() { - return ( -
-
-

- Företagsmoduler -

-

- Välj din bransch för att se anpassade moduler och funktioner. -

-
- -
- -
- -
- - Visa alla - - - - {sectors.map((sector) => { - const Icon = sector.icon - return ( - -
- -
- - {sector.name} - - - ) - })} -
-
- ) -} diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index c08e221a..fdb3e805 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -122,13 +122,6 @@ export default async function DashboardPage() { (inv) => inv.status === 'overdue' ).length - // Fetch mileage entries for schablonavdrag - const { data: mileageEntries } = await supabase - .from('mileage_entries') - .select('*') - .eq('user_id', user.id) - .gte('date', startOfYear.split('T')[0]) - // Fetch bank balance (if connected) const { data: bankConnections } = await supabase .from('bank_connections') @@ -225,7 +218,6 @@ export default async function DashboardPage() { unpaidVatTotal, overdueInvoicesCount: overdueCount, bankBalance, - mileageEntries: mileageEntries || [], deadlines: (deadlines || []) as Deadline[], receiptQueue, }} diff --git a/app/(dashboard)/receipts/page.tsx b/app/(dashboard)/receipts/page.tsx index 97435172..4aad8998 100644 --- a/app/(dashboard)/receipts/page.tsx +++ b/app/(dashboard)/receipts/page.tsx @@ -1,344 +1 @@ -'use client' - -import { useState, useEffect, useCallback } from 'react' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { Skeleton } from '@/components/ui/skeleton' -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { - Camera, - Receipt, - Check, - Clock, - AlertCircle, - ArrowRight, -} from 'lucide-react' -import { formatCurrency, formatDate } from '@/lib/utils' -import { useRouter } from 'next/navigation' -import ReceiptDashboard from '@/components/receipts/ReceiptDashboard' -import ReceiptReviewView from '@/components/receipts/ReceiptReviewView' -import TransactionMatcher from '@/components/receipts/TransactionMatcher' -import type { Receipt as ReceiptType, ReceiptLineItem, ReceiptQueueSummary, ConfirmLineItemInput } from '@/types' - -type ViewMode = 'dashboard' | 'list' | 'review' | 'match' -type ListFilter = 'all' | 'pending' | 'confirmed' - -export default function ReceiptsPage() { - const router = useRouter() - const [viewMode, setViewMode] = useState('dashboard') - const [listFilter, setListFilter] = useState('all') - - const [receipts, setReceipts] = useState<(ReceiptType & { line_items: ReceiptLineItem[] })[]>([]) - const [summary, setSummary] = useState(null) - const [isLoading, setIsLoading] = useState(true) - - // Selected receipt for review/match - const [selectedReceipt, setSelectedReceipt] = useState<(ReceiptType & { line_items: ReceiptLineItem[] }) | null>(null) - - // Fetch receipts and summary - const fetchData = useCallback(async () => { - setIsLoading(true) - try { - const [receiptsRes, queueRes] = await Promise.all([ - fetch('/api/receipts'), - fetch('/api/receipts/queue'), - ]) - - const [receiptsData, queueData] = await Promise.all([ - receiptsRes.json(), - queueRes.json(), - ]) - - if (receiptsData.data) { - setReceipts(receiptsData.data) - } - - if (queueData.data?.summary) { - setSummary(queueData.data.summary) - } - } catch (error) { - console.error('Fetch error:', error) - } finally { - setIsLoading(false) - } - }, []) - - useEffect(() => { - fetchData() - }, [fetchData]) - - // Filter receipts based on tab - const filteredReceipts = receipts.filter((r) => { - if (listFilter === 'pending') return r.status === 'extracted' - if (listFilter === 'confirmed') return r.status === 'confirmed' - return true - }) - - // Handle scan receipt - const handleScanReceipt = () => { - router.push('/receipts/scan') - } - - // Handle view receipt queue - const handleViewReceiptQueue = () => { - setListFilter('pending') - setViewMode('list') - } - - // Handle view transaction queue - const handleViewTransactionQueue = () => { - router.push('/transactions?filter=unmatched') - } - - // Handle receipt selection for review - const handleSelectReceipt = (receipt: ReceiptType & { line_items: ReceiptLineItem[] }) => { - setSelectedReceipt(receipt) - if (receipt.status === 'extracted') { - setViewMode('review') - } else { - setViewMode('match') - } - } - - // Handle confirm receipt - const handleConfirmReceipt = async (data: { - line_items: ConfirmLineItemInput[] - representation_persons?: number - representation_purpose?: string - }) => { - if (!selectedReceipt) return - - const response = await fetch(`/api/receipts/${selectedReceipt.id}/confirm`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - - if (response.ok) { - await fetchData() - setSelectedReceipt(null) - setViewMode('dashboard') - } else { - const errorData = await response.json() - throw new Error(errorData.error || 'Kunde inte bekräfta kvitto') - } - } - - // Handle match receipt to transaction - const handleMatchReceipt = async (transactionId: string, confidence: number) => { - if (!selectedReceipt) return - - const response = await fetch(`/api/receipts/${selectedReceipt.id}/match`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }), - }) - - if (response.ok) { - await fetchData() - setSelectedReceipt(null) - setViewMode('dashboard') - } - } - - // Render receipt review view - if (viewMode === 'review' && selectedReceipt) { - return ( - { - setSelectedReceipt(null) - setViewMode('dashboard') - }} - onFindMatches={() => setViewMode('match')} - /> - ) - } - - // Render transaction matcher - if (viewMode === 'match' && selectedReceipt) { - return ( - { - setSelectedReceipt(null) - setViewMode('dashboard') - }} - onClose={() => { - setSelectedReceipt(null) - setViewMode('dashboard') - }} - /> - ) - } - - // Render main page - return ( -
-
-

Kvittohantering

- -
- - {/* Dashboard or List view toggle */} - setViewMode(v as ViewMode)} - > - - Översikt - Alla kvitton - - - - {viewMode === 'dashboard' && ( - <> - {isLoading ? ( -
-
- - -
- - -
- ) : summary ? ( - - ) : ( - - - -

Kunde inte ladda data

- -
-
- )} - - )} - - {viewMode === 'list' && ( - <> - {/* List filters */} -
- - - -
- - {/* Receipt list */} - {isLoading ? ( -
- {[1, 2, 3].map((i) => ( - - ))} -
- ) : filteredReceipts.length === 0 ? ( - - - -

Inga kvitton hittades

-
-
- ) : ( -
- {filteredReceipts.map((receipt) => ( - handleSelectReceipt(receipt)} - > - -
-
- {receipt.image_url ? ( - Receipt - ) : ( -
- -
- )} -
-

{receipt.merchant_name || 'Okänt kvitto'}

- {receipt.receipt_date && ( -

- {formatDate(receipt.receipt_date)} -

- )} -
- - {receipt.status === 'confirmed' && 'Bekräftat'} - {receipt.status === 'extracted' && 'Att granska'} - {receipt.status === 'processing' && 'Analyserar...'} - {receipt.status === 'pending' && 'Väntar'} - {receipt.status === 'error' && 'Fel'} - - {receipt.matched_transaction_id && ( - Kopplat - )} -
-
-
-
-

- {formatCurrency(receipt.total_amount || 0, receipt.currency)} -

- -
-
-
-
- ))} -
- )} - - )} -
- ) -} +export { default } from '@/extensions/receipt-ocr/pages/ReceiptsPage' diff --git a/app/(dashboard)/receipts/scan/page.tsx b/app/(dashboard)/receipts/scan/page.tsx index 5c57d21a..3d99fe62 100644 --- a/app/(dashboard)/receipts/scan/page.tsx +++ b/app/(dashboard)/receipts/scan/page.tsx @@ -1,157 +1 @@ -'use client' - -import { useState } from 'react' -import { useRouter } from 'next/navigation' -import ReceiptCamera from '@/components/receipts/ReceiptCamera' -import ReceiptReviewView from '@/components/receipts/ReceiptReviewView' -import TransactionMatcher from '@/components/receipts/TransactionMatcher' -import { Loader2 } from 'lucide-react' -import type { Receipt, ReceiptLineItem, ConfirmLineItemInput } from '@/types' - -type PageState = 'camera' | 'uploading' | 'review' | 'match' | 'done' - -export default function ScanReceiptPage() { - const router = useRouter() - const [pageState, setPageState] = useState('camera') - const [uploadError, setUploadError] = useState(null) - const [receipt, setReceipt] = useState<(Receipt & { line_items: ReceiptLineItem[] }) | null>(null) - - // Handle image capture - const handleCapture = async (imageData: string, mimeType: string) => { - setPageState('uploading') - setUploadError(null) - - try { - // Convert base64 to blob - const byteCharacters = atob(imageData) - const byteNumbers = new Array(byteCharacters.length) - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i) - } - const byteArray = new Uint8Array(byteNumbers) - const blob = new Blob([byteArray], { type: mimeType }) - - // Create form data - const formData = new FormData() - formData.append('image', blob, 'receipt.jpg') - - // Upload and analyze - const response = await fetch('/api/receipts/upload', { - method: 'POST', - body: formData, - }) - - const data = await response.json() - - if (response.ok && data.data) { - setReceipt(data.data) - setPageState('review') - } else { - setUploadError(data.error || 'Kunde inte analysera kvittot') - setPageState('camera') - } - } catch (error) { - console.error('Upload error:', error) - setUploadError('Nätverksfel. Försök igen.') - setPageState('camera') - } - } - - // Handle close/cancel - const handleClose = () => { - router.push('/receipts') - } - - // Handle confirm receipt - const handleConfirm = async (data: { - line_items: ConfirmLineItemInput[] - representation_persons?: number - representation_purpose?: string - }) => { - if (!receipt) return - - const response = await fetch(`/api/receipts/${receipt.id}/confirm`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - - if (response.ok) { - const updatedData = await response.json() - setReceipt(updatedData.data) - setPageState('match') - } else { - const errorData = await response.json() - throw new Error(errorData.error || 'Kunde inte bekräfta kvitto') - } - } - - // Handle match to transaction - const handleMatch = async (transactionId: string, confidence: number) => { - if (!receipt) return - - const response = await fetch(`/api/receipts/${receipt.id}/match`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }), - }) - - if (response.ok) { - router.push('/receipts') - } - } - - // Handle skip matching - const handleSkipMatch = () => { - router.push('/receipts') - } - - // Render uploading state - if (pageState === 'uploading') { - return ( -
- -

Analyserar kvitto...

-

- AI läser av artiklar och belopp -

-
- ) - } - - // Render review view - if (pageState === 'review' && receipt) { - return ( - setPageState('match')} - /> - ) - } - - // Render match view - if (pageState === 'match' && receipt) { - return ( - - ) - } - - // Render camera (default) - return ( - <> - {uploadError && ( -
- {uploadError} -
- )} - - - ) -} +export { default } from '@/extensions/receipt-ocr/pages/scan/ScanReceiptPage' diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 2dcbf35d..019b14ba 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -6,7 +6,9 @@ import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' -import { Download, FileText, TrendingUp, Scale, AlertCircle, Receipt, Briefcase } from 'lucide-react' +import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase } from 'lucide-react' +import { NEDeclarationView } from '@/extensions/ne-bilaga/NEDeclarationView' +import { SRUExportView } from '@/extensions/sru-export/SRUExportView' import type { FiscalPeriod, TrialBalanceRow, @@ -14,7 +16,6 @@ import type { BalanceSheetReport, VatDeclaration, VatPeriodType, - NEDeclaration, } from '@/types' function formatAmount(amount: number): string { @@ -25,9 +26,11 @@ export default function ReportsPage() { const [periods, setPeriods] = useState([]) const [selectedPeriod, setSelectedPeriod] = useState('') const [activeTab, setActiveTab] = useState('trial-balance') + const [entityType, setEntityType] = useState(null) useEffect(() => { fetchPeriods() + fetchEntityType() }, []) async function fetchPeriods() { @@ -39,13 +42,27 @@ export default function ReportsPage() { } } + async function fetchEntityType() { + try { + const res = await fetch('/api/settings') + const { data } = await res.json() + if (data?.entity_type) { + setEntityType(data.entity_type) + } + } catch { + // Ignore - entity type is optional for tab visibility + } + } + + const isEnskildFirma = entityType === 'enskild_firma' + return (

Rapporter

- Saldobalans, resultaträkning, balansräkning, momsdeklaration, NE-bilaga och SIE-export + Saldobalans, resultaträkning, balansräkning, momsdeklaration, NE-bilaga, SRU-export och SIE-export

@@ -97,9 +114,15 @@ export default function ReportsPage() { Momsdeklaration - - - NE-bilaga + {isEnskildFirma && ( + + + NE-bilaga + + )} + + + SRU-export @@ -115,8 +138,13 @@ export default function ReportsPage() { - - + {isEnskildFirma && ( + + + + )} + + ) : ( @@ -870,287 +898,3 @@ function VatRutaRow({ ) } - -function NEDeclarationView({ periodId }: { periodId: string }) { - const [data, setData] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - - const fetchDeclaration = async () => { - setLoading(true) - setError(null) - try { - const res = await fetch(`/api/reports/ne-declaration?period_id=${periodId}`) - const result = await res.json() - if (result.error) { - setError(result.error) - } else { - setData(result.data) - } - } catch { - setError('Kunde inte hämta NE-bilaga') - } finally { - setLoading(false) - } - } - - const downloadSRU = () => { - window.open(`/api/reports/ne-declaration?period_id=${periodId}&format=sru`, '_blank') - } - - // NE ruta labels - const rutaLabels: Record = { - R1: 'Försäljning med moms (25%)', - R2: 'Momsfria intäkter', - R3: 'Bil/bostadsförmån', - R4: 'Ränteintäkter', - R5: 'Varuinköp', - R6: 'Övriga kostnader', - R7: 'Lönekostnader', - R8: 'Räntekostnader', - R9: 'Avskrivningar fastighet', - R10: 'Avskrivningar övriga tillgångar', - R11: 'Årets resultat', - } - - // Categorize rutor - const revenueRutor = ['R1', 'R2', 'R3', 'R4'] as const - const expenseRutor = ['R5', 'R6', 'R7', 'R8', 'R9', 'R10'] as const - - return ( -
- {/* Info card */} - - - NE-bilaga (Enskild firma) - - -

- NE-bilagan används för att deklarera resultat från enskild näringsverksamhet. - Uppgifterna hämtas från bokföringen för valt räkenskapsår. -

-
- - {data && ( - - )} -
-
-
- - {error && ( - - - - {error} - - - )} - - {data && ( - <> - {/* Warnings */} - {data.warnings.length > 0 && ( - - -
- -
- {data.warnings.map((warning, i) => ( -

{warning}

- ))} -
-
-
-
- )} - - {/* Company info */} - - -
- - {data.companyInfo.companyName} - - - {data.fiscalYear.name} - -
- {data.companyInfo.orgNumber && ( -

- Org.nr: {data.companyInfo.orgNumber} -

- )} -
-
- - {/* Revenue section */} - - - Intäkter - - - - - {revenueRutor.map((ruta) => { - const value = data.rutor[ruta] - const breakdown = data.breakdown[ruta] - return ( - - ) - })} - - - - - - - -
Summa intäkter - {formatAmount( - data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4 - )} kr -
-
-
- - {/* Expenses section */} - - - Kostnader - - - - - {expenseRutor.map((ruta) => { - const value = data.rutor[ruta] - const breakdown = data.breakdown[ruta] - return ( - - ) - })} - - - - - - - -
Summa kostnader - -{formatAmount( - data.rutor.R5 + data.rutor.R6 + data.rutor.R7 + - data.rutor.R8 + data.rutor.R9 + data.rutor.R10 - )} kr -
-
-
- - {/* Result */} - - -
-
- R11 - Årets resultat -
- = 0 ? 'text-green-600' : 'text-red-600' - }`} - > - {formatAmount(data.rutor.R11)} kr - -
-
-
- - )} - - {!data && !loading && !error && ( - - - Klicka "Hämta NE-bilaga" för att generera deklarationsunderlaget. - - - )} -
- ) -} - -function NEDeclarationRow({ - ruta, - label, - amount, - accounts, - isExpense, -}: { - ruta: string - label: string - amount: number - accounts: Array<{ accountNumber: string; accountName: string; amount: number }> - isExpense?: boolean -}) { - const [expanded, setExpanded] = useState(false) - - // Don't show rows with zero values - if (amount === 0 && accounts.length === 0) return null - - return ( - <> - accounts.length > 0 && setExpanded(!expanded)} - > - - {ruta} - {label} - {accounts.length > 0 && ( - - ({accounts.length} konton) - - )} - - - {isExpense && amount > 0 ? '-' : ''}{formatAmount(Math.abs(amount))} kr - - - {expanded && accounts.length > 0 && ( - - - - - {accounts.map((acc) => ( - - - - - - ))} - -
{acc.accountNumber}{acc.accountName} - {isExpense && acc.amount > 0 ? '-' : ''}{formatAmount(Math.abs(acc.amount))} kr -
- - - )} - - ) -} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 7df64170..0d65b167 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -26,7 +26,7 @@ import { } from 'lucide-react' import type { CompanySettings, BankConnection } from '@/types' import { BankSelector, type Bank } from '@/components/banking/BankSelector' -import { NotificationSettings } from '@/components/settings/NotificationSettings' +import { NotificationSettings } from '@/extensions/push-notifications/NotificationSettings' import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings' export default function SettingsPage() { diff --git a/app/api/chat/route.ts b/app/api/extensions/ai-chat/route.ts similarity index 96% rename from app/api/chat/route.ts rename to app/api/extensions/ai-chat/route.ts index 6a0c9bda..81cf364e 100644 --- a/app/api/chat/route.ts +++ b/app/api/extensions/ai-chat/route.ts @@ -1,7 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { generateChatResponse } from '@/lib/ai/chatbot/chain' -import { CHATBOT_CONFIG } from '@/lib/ai/chatbot/config' +import { generateChatResponse } from '@/extensions/ai-chat/chatbot/chain' +import { CHATBOT_CONFIG } from '@/extensions/ai-chat/chatbot/config' import type { ChatMessage, ChatRequest } from '@/types/chat' // Simple in-memory rate limiting (per user) diff --git a/app/api/chat/sessions/[id]/route.ts b/app/api/extensions/ai-chat/sessions/[id]/route.ts similarity index 100% rename from app/api/chat/sessions/[id]/route.ts rename to app/api/extensions/ai-chat/sessions/[id]/route.ts diff --git a/app/api/chat/sessions/route.ts b/app/api/extensions/ai-chat/sessions/route.ts similarity index 100% rename from app/api/chat/sessions/route.ts rename to app/api/extensions/ai-chat/sessions/route.ts diff --git a/app/api/chat/stream/route.ts b/app/api/extensions/ai-chat/stream/route.ts similarity index 97% rename from app/api/chat/stream/route.ts rename to app/api/extensions/ai-chat/stream/route.ts index fd06bf50..0edc89d3 100644 --- a/app/api/chat/stream/route.ts +++ b/app/api/extensions/ai-chat/stream/route.ts @@ -1,6 +1,6 @@ import { createClient } from '@/lib/supabase/server' -import { streamChatResponse } from '@/lib/ai/chatbot/chain' -import { CHATBOT_CONFIG } from '@/lib/ai/chatbot/config' +import { streamChatResponse } from '@/extensions/ai-chat/chatbot/chain' +import { CHATBOT_CONFIG } from '@/extensions/ai-chat/chatbot/config' import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat' // Simple in-memory rate limiting (per user) diff --git a/app/api/reports/ne-declaration/route.ts b/app/api/extensions/ne-bilaga/route.ts similarity index 92% rename from app/api/reports/ne-declaration/route.ts rename to app/api/extensions/ne-bilaga/route.ts index 4e86cf7a..a92f626f 100644 --- a/app/api/reports/ne-declaration/route.ts +++ b/app/api/extensions/ne-bilaga/route.ts @@ -1,14 +1,14 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { generateNEDeclaration } from '@/lib/reports/ne-declaration' +import { generateNEDeclaration } from '@/extensions/ne-bilaga/ne-engine' import { generateSRUFile, sruFileToString, getSRUFilename, -} from '@/lib/reports/sru-generator' +} from '@/extensions/sru-export/lib/sru-generator' /** - * GET /api/reports/ne-declaration + * GET /api/extensions/ne-bilaga * * Generate NE declaration (NE-bilaga) for enskild firma. * diff --git a/app/api/push/cron/route.ts b/app/api/extensions/push-notifications/cron/route.ts similarity index 88% rename from app/api/push/cron/route.ts rename to app/api/extensions/push-notifications/cron/route.ts index 79f4e683..1c5cc5b6 100644 --- a/app/api/push/cron/route.ts +++ b/app/api/extensions/push-notifications/cron/route.ts @@ -1,18 +1,22 @@ import { createClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { loadExtensions } from '@/lib/extensions/loader' import { sendTaxDeadlineNotifications, sendInvoiceNotifications, -} from '@/lib/push/notification-scheduler' +} from '@/extensions/push-notifications/notification-scheduler' /** - * GET /api/push/cron + * GET /api/extensions/push-notifications/cron * Daily cron job to send push notifications * Runs at 09:00 every day * * Vercel Cron: "0 9 * * *" */ export async function GET(request: Request) { + // Ensure extensions are loaded so event handlers are registered + loadExtensions() + // Verify cron secret for security const authHeader = request.headers.get('authorization') const cronSecret = process.env.CRON_SECRET diff --git a/app/api/extensions/push-notifications/settings/route.ts b/app/api/extensions/push-notifications/settings/route.ts new file mode 100644 index 00000000..78891244 --- /dev/null +++ b/app/api/extensions/push-notifications/settings/route.ts @@ -0,0 +1,61 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { getSettings, saveSettings } from '@/extensions/push-notifications' + +/** + * GET /api/extensions/push-notifications/settings + * Get the current user's push-notification extension settings + */ +export async function GET() { + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const settings = await getSettings(user.id) + return NextResponse.json({ data: settings }) +} + +/** + * PATCH /api/extensions/push-notifications/settings + * Update the current user's push-notification extension settings + */ +export async function PATCH(request: Request) { + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + + const allowedKeys = [ + 'periodLockedEnabled', + 'periodYearClosedEnabled', + 'invoiceSentEnabled', + 'receiptExtractedEnabled', + 'receiptMatchedEnabled', + ] + const filtered: Record = {} + for (const key of allowedKeys) { + if (key in body) { + filtered[key] = body[key] + } + } + + if (Object.keys(filtered).length === 0) { + return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 }) + } + + const settings = await saveSettings(user.id, filtered) + return NextResponse.json({ data: settings }) +} diff --git a/app/api/push/subscribe/route.ts b/app/api/extensions/push-notifications/subscribe/route.ts similarity index 92% rename from app/api/push/subscribe/route.ts rename to app/api/extensions/push-notifications/subscribe/route.ts index 72aa0bed..50e226c3 100644 --- a/app/api/push/subscribe/route.ts +++ b/app/api/extensions/push-notifications/subscribe/route.ts @@ -1,9 +1,9 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { getVapidPublicKey } from '@/lib/push/web-push' +import { getVapidPublicKey } from '@/extensions/push-notifications/notification-sender' /** - * GET /api/push/subscribe + * GET /api/extensions/push-notifications/subscribe * Get the VAPID public key for client-side subscription */ export async function GET() { @@ -20,7 +20,7 @@ export async function GET() { } /** - * POST /api/push/subscribe + * POST /api/extensions/push-notifications/subscribe * Save a new push subscription */ export async function POST(request: Request) { @@ -96,7 +96,7 @@ export async function POST(request: Request) { } /** - * DELETE /api/push/subscribe + * DELETE /api/extensions/push-notifications/subscribe * Remove a push subscription */ export async function DELETE(request: Request) { diff --git a/app/api/receipts/[id]/confirm/route.ts b/app/api/extensions/receipt-ocr/[id]/confirm/route.ts similarity index 100% rename from app/api/receipts/[id]/confirm/route.ts rename to app/api/extensions/receipt-ocr/[id]/confirm/route.ts diff --git a/app/api/receipts/[id]/match/route.ts b/app/api/extensions/receipt-ocr/[id]/match/route.ts similarity index 98% rename from app/api/receipts/[id]/match/route.ts rename to app/api/extensions/receipt-ocr/[id]/match/route.ts index b161132a..1eff3cbe 100644 --- a/app/api/receipts/[id]/match/route.ts +++ b/app/api/extensions/receipt-ocr/[id]/match/route.ts @@ -1,6 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { findTransactionMatches } from '@/lib/receipts/receipt-matcher' +import { findTransactionMatches } from '@/extensions/receipt-ocr/lib/receipt-matcher' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' import type { Receipt, Transaction } from '@/types' diff --git a/app/api/receipts/[id]/route.ts b/app/api/extensions/receipt-ocr/[id]/route.ts similarity index 100% rename from app/api/receipts/[id]/route.ts rename to app/api/extensions/receipt-ocr/[id]/route.ts diff --git a/app/api/receipts/queue/route.ts b/app/api/extensions/receipt-ocr/queue/route.ts similarity index 100% rename from app/api/receipts/queue/route.ts rename to app/api/extensions/receipt-ocr/queue/route.ts diff --git a/app/api/receipts/route.ts b/app/api/extensions/receipt-ocr/route.ts similarity index 100% rename from app/api/receipts/route.ts rename to app/api/extensions/receipt-ocr/route.ts diff --git a/app/api/receipts/upload/route.ts b/app/api/extensions/receipt-ocr/upload/route.ts similarity index 97% rename from app/api/receipts/upload/route.ts rename to app/api/extensions/receipt-ocr/upload/route.ts index bc55bba9..aba9bcc9 100644 --- a/app/api/receipts/upload/route.ts +++ b/app/api/extensions/receipt-ocr/upload/route.ts @@ -1,7 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { analyzeReceipt } from '@/lib/receipts/receipt-analyzer' -import { processLineItems } from '@/lib/receipts/receipt-categorizer' +import { analyzeReceipt } from '@/extensions/receipt-ocr/lib/receipt-analyzer' +import { processLineItems } from '@/extensions/receipt-ocr/lib/receipt-categorizer' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' diff --git a/app/api/extensions/sru-export/coverage/route.ts b/app/api/extensions/sru-export/coverage/route.ts new file mode 100644 index 00000000..43a27f9f --- /dev/null +++ b/app/api/extensions/sru-export/coverage/route.ts @@ -0,0 +1,29 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { getSRUCoverage } from '@/extensions/sru-export/sru-engine' + +/** + * GET /api/extensions/sru-export/coverage + * + * Returns SRU code coverage stats: how many accounts have SRU codes vs total, + * and a list of accounts missing codes. + */ +export async function GET() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const coverage = await getSRUCoverage(user.id) + return NextResponse.json({ data: coverage }) + } catch (err) { + console.error('Error fetching SRU coverage:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to fetch SRU coverage' }, + { status: 500 } + ) + } +} diff --git a/app/api/extensions/sru-export/route.ts b/app/api/extensions/sru-export/route.ts new file mode 100644 index 00000000..16625e4f --- /dev/null +++ b/app/api/extensions/sru-export/route.ts @@ -0,0 +1,150 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { aggregateBalancesBySRU } from '@/extensions/sru-export/sru-engine' +import { + generateGenericSRU, + getGenericSRUFilename, + sruFileToString, + validateSRUFile, + SRU_CODE_DESCRIPTIONS, +} from '@/extensions/sru-export/sru-generator' +import type { SRUFormType } from '@/extensions/sru-export/sru-generator' +import type { EntityType } from '@/types' + +/** + * GET /api/extensions/sru-export + * + * Generate SRU export from chart_of_accounts sru_code mappings. + * + * Query parameters: + * - period_id: Fiscal period ID (required) + * - format: 'json' (default) or 'sru' for downloadable file + */ +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') + const format = searchParams.get('format') || 'json' + + if (!periodId) { + return NextResponse.json( + { error: 'period_id is required' }, + { status: 400 } + ) + } + + try { + // Fetch company settings to determine entity type + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type, company_name, org_number') + .eq('user_id', user.id) + .single() + + if (!settings) { + return NextResponse.json( + { error: 'Company settings not found. Complete onboarding first.' }, + { status: 400 } + ) + } + + // Determine form type from entity type + const formType: SRUFormType = settings.entity_type === 'aktiebolag' ? 'INK2' : 'NE' + + // Fetch fiscal period for date range + const { data: period } = await supabase + .from('fiscal_periods') + .select('*') + .eq('id', periodId) + .eq('user_id', user.id) + .single() + + if (!period) { + return NextResponse.json( + { error: 'Fiscal period not found' }, + { status: 404 } + ) + } + + // Aggregate balances by SRU code + const sruBalances = await aggregateBalancesBySRU(user.id, periodId) + + if (format === 'sru') { + // Generate and return SRU file + const sruFile = generateGenericSRU({ + formType, + orgNumber: settings.org_number, + companyName: settings.company_name || 'Okänt företag', + fiscalYearStart: period.period_start, + fiscalYearEnd: period.period_end, + sruBalances, + }) + + const validation = validateSRUFile(sruFile) + if (!validation.isValid) { + return NextResponse.json( + { error: 'SRU validation failed', details: validation.errors }, + { status: 500 } + ) + } + + const sruContent = sruFileToString(sruFile) + const filename = getGenericSRUFilename(formType, settings.org_number, period.period_start) + + return new NextResponse(sruContent, { + status: 200, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + } + + // Default: return JSON preview + const balancesArray = Array.from(sruBalances.values()) + .sort((a, b) => a.sruCode.localeCompare(b.sruCode)) + .map((b) => ({ + sruCode: b.sruCode, + description: SRU_CODE_DESCRIPTIONS[b.sruCode] || `SRU ${b.sruCode}`, + amount: b.amount, + accounts: b.accounts, + })) + + const warnings: string[] = [] + if (!period.is_closed) { + warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.') + } + if (balancesArray.length === 0) { + warnings.push('Inga konton med SRU-koder har bokförda poster i denna period.') + } + + return NextResponse.json({ + data: { + formType, + entityType: settings.entity_type as EntityType, + companyName: settings.company_name, + orgNumber: settings.org_number, + fiscalYear: { + id: period.id, + name: period.name, + start: period.period_start, + end: period.period_end, + }, + balances: balancesArray, + warnings, + }, + }) + } catch (err) { + console.error('Error generating SRU export:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to generate SRU export' }, + { status: 500 } + ) + } +} diff --git a/app/globals.css b/app/globals.css index 2b09b209..67204d5d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -8,85 +8,84 @@ html, :root { @media (prefers-color-scheme: dark) { :root { - --background: 40 30% 97% !important; - --foreground: 30 10% 15% !important; - --card: 40 40% 99% !important; - --card-foreground: 30 10% 15% !important; - --popover: 40 40% 99% !important; - --popover-foreground: 30 10% 15% !important; - --primary: 28 40% 35% !important; - --primary-foreground: 40 40% 99% !important; - --secondary: 35 25% 93% !important; - --secondary-foreground: 30 10% 15% !important; - --muted: 35 15% 91% !important; - --muted-foreground: 30 8% 45% !important; - --accent: 18 45% 55% !important; - --accent-foreground: 40 40% 99% !important; - --destructive: 0 45% 55% !important; - --destructive-foreground: 40 40% 99% !important; - --border: 35 20% 88% !important; - --input: 35 20% 88% !important; - --ring: 28 40% 35% !important; + --background: 220 14% 96% !important; + --foreground: 224 12% 13% !important; + --card: 0 0% 100% !important; + --card-foreground: 224 12% 13% !important; + --popover: 0 0% 100% !important; + --popover-foreground: 224 12% 13% !important; + --primary: 222 47% 35% !important; + --primary-foreground: 0 0% 100% !important; + --secondary: 220 13% 93% !important; + --secondary-foreground: 224 12% 13% !important; + --muted: 218 12% 91% !important; + --muted-foreground: 218 8% 46% !important; + --accent: 213 72% 50% !important; + --accent-foreground: 0 0% 100% !important; + --destructive: 0 68% 50% !important; + --destructive-foreground: 0 0% 100% !important; + --border: 218 14% 89% !important; + --input: 218 14% 89% !important; + --ring: 222 47% 35% !important; } } :root { - /* Nordic Warmth Color Palette */ - /* Inspired by Swedish nature: forest, stone, linen, birch */ + /* Clean Slate — Modern ERP Palette */ - --background: 40 30% 97%; /* Warm paper white */ - --foreground: 30 10% 15%; /* Soft charcoal, not harsh black */ + --background: 220 14% 96%; /* Cool off-white #F2F4F7 */ + --foreground: 224 12% 13%; /* Deep slate #1D2029 */ - --card: 40 40% 99%; /* Natural white with warmth */ - --card-foreground: 30 10% 15%; + --card: 0 0% 100%; /* Pure white */ + --card-foreground: 224 12% 13%; - --popover: 40 40% 99%; - --popover-foreground: 30 10% 15%; + --popover: 0 0% 100%; + --popover-foreground: 224 12% 13%; - /* Primary - Leather Brown (warm, dark) */ - --primary: 28 40% 35%; /* Dark leather brown */ - --primary-foreground: 40 40% 99%; + /* Primary — Refined navy-blue */ + --primary: 222 47% 35%; /* #304D83 */ + --primary-foreground: 0 0% 100%; - /* Secondary - Warm Linen */ - --secondary: 35 25% 93%; /* Soft linen/oat */ - --secondary-foreground: 30 10% 15%; + /* Secondary — Cool pale gray */ + --secondary: 220 13% 93%; /* #EBEDF1 */ + --secondary-foreground: 224 12% 13%; - /* Muted - Stone */ - --muted: 35 15% 91%; /* Warm stone gray */ - --muted-foreground: 30 8% 45%; /* Soft warm gray */ + /* Muted — Neutral gray */ + --muted: 218 12% 91%; /* #E3E6EB */ + --muted-foreground: 218 8% 46%; /* #6B7280 */ - /* Accent - Terracotta */ - --accent: 18 45% 55%; /* Muted terracotta/clay */ - --accent-foreground: 40 40% 99%; + /* Accent — Clear blue for interactive highlights */ + --accent: 213 72% 50%; /* #2373D8 */ + --accent-foreground: 0 0% 100%; - /* Destructive - Muted Rose */ - --destructive: 0 45% 55%; /* Soft dusty rose */ - --destructive-foreground: 40 40% 99%; + /* Destructive — Clean red */ + --destructive: 0 68% 50%; /* #D63232 */ + --destructive-foreground: 0 0% 100%; - /* Borders & Input - Soft warm line */ - --border: 35 20% 88%; - --input: 35 20% 88%; - --ring: 28 40% 35%; + /* Borders & Input */ + --border: 218 14% 89%; /* #DEE1E7 */ + --input: 218 14% 89%; + --ring: 222 47% 35%; - /* Radius - Subtle, refined */ + /* Radius */ --radius: 0.5rem; - /* Success - Forest green */ - --success: 150 30% 40%; - --success-foreground: 40 40% 99%; + /* Success — Clean green */ + --success: 152 48% 38%; /* #329460 */ + --success-foreground: 0 0% 100%; - /* Warning - Warm honey */ - --warning: 38 70% 55%; - --warning-foreground: 30 10% 15%; + /* Warning — Clear amber */ + --warning: 38 92% 50%; /* #F59E0B */ + --warning-foreground: 224 12% 13%; /* Custom tokens */ --font-display: var(--font-fraunces), Georgia, serif; --font-body: var(--font-geist-sans), system-ui, sans-serif; - /* Refined shadows */ - --shadow-sm: 0 1px 2px rgba(30, 25, 20, 0.04); - --shadow-md: 0 4px 12px rgba(30, 25, 20, 0.06); - --shadow-lg: 0 12px 40px rgba(30, 25, 20, 0.08); + /* Shadows — cool-toned */ + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04); + --shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06); + --shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.08); /* Animation */ --ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94); @@ -151,7 +150,7 @@ body { h1, h2, h3 { font-family: var(--font-display); font-weight: 500; - letter-spacing: -0.02em; + letter-spacing: -0.015em; font-optical-sizing: auto; } @@ -244,13 +243,13 @@ h1, h2, h3 { /* Refined hover effects */ .hover-lift { - transition: transform var(--duration-base) var(--ease-out), - box-shadow var(--duration-base) var(--ease-out); + transition: transform var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out); } .hover-lift:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-lg); + transform: translateY(-1px); + box-shadow: var(--shadow-md); } /* Soft focus states */ diff --git a/app/layout.tsx b/app/layout.tsx index 6dac3f6a..56d6faa9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -33,7 +33,7 @@ export const metadata: Metadata = { }; export const viewport: Viewport = { - themeColor: "#1a1a1a", + themeColor: "#304D83", width: "device-width", initialScale: 1, }; diff --git a/app/page.tsx b/app/page.tsx index 67228f49..39b0883f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -86,13 +86,6 @@ export default async function RootPage() { (inv) => inv.status === 'overdue' ).length - // Fetch mileage entries for schablonavdrag - const { data: mileageEntries } = await supabase - .from('mileage_entries') - .select('*') - .eq('user_id', user.id) - .gte('date', startOfYear.split('T')[0]) - // Fetch bank balance (if connected) const { data: bankConnections } = await supabase .from('bank_connections') @@ -192,7 +185,6 @@ export default async function RootPage() { unpaidVatTotal, overdueInvoicesCount: overdueCount, bankBalance, - mileageEntries: mileageEntries || [], deadlines: (deadlines || []) as Deadline[], receiptQueue, }} diff --git a/components/bookkeeping/ChartOfAccounts.tsx b/components/bookkeeping/ChartOfAccounts.tsx index b5d90af6..f03cb8e6 100644 --- a/components/bookkeeping/ChartOfAccounts.tsx +++ b/components/bookkeeping/ChartOfAccounts.tsx @@ -25,6 +25,8 @@ export default function ChartOfAccounts() { const [loading, setLoading] = useState(true) const [expandedClasses, setExpandedClasses] = useState>(new Set()) const [searchQuery, setSearchQuery] = useState('') + const [editingSRU, setEditingSRU] = useState(null) + const [sruValue, setSruValue] = useState('') useEffect(() => { fetchAccounts() @@ -37,6 +39,20 @@ export default function ChartOfAccounts() { setLoading(false) } + async function updateSRUCode(accountId: string, newSruCode: string) { + const supabase = createClient() + const trimmed = newSruCode.trim() || null + await supabase + .from('chart_of_accounts') + .update({ sru_code: trimmed }) + .eq('id', accountId) + + setAccounts((prev) => + prev.map((a) => (a.id === accountId ? { ...a, sru_code: trimmed } : a)) + ) + setEditingSRU(null) + } + const toggleClass = (cls: number) => { const next = new Set(expandedClasses) if (next.has(cls)) { @@ -121,6 +137,7 @@ export default function ChartOfAccounts() { Konto Namn + SRU Typ Normal @@ -135,6 +152,32 @@ export default function ChartOfAccounts() { > {account.account_number} {account.account_name} + + {editingSRU === account.id ? ( + setSruValue(e.target.value)} + onBlur={() => updateSRUCode(account.id, sruValue)} + onKeyDown={(e) => { + if (e.key === 'Enter') updateSRUCode(account.id, sruValue) + if (e.key === 'Escape') setEditingSRU(null) + }} + className="h-7 w-16 text-xs text-center px-1" + autoFocus + /> + ) : ( + + )} + {account.account_type === 'asset' diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index f8f72afc..1ef9acbf 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -45,7 +45,7 @@ export default function JournalEntryList({ periodId }: Props) { const statusLabel = (status: string) => { switch (status) { case 'posted': - return Bokförd + return Bokförd case 'draft': return Utkast case 'reversed': diff --git a/components/calendar/CalendarDayView.tsx b/components/calendar/CalendarDayView.tsx index e32ea995..c5480b1a 100644 --- a/components/calendar/CalendarDayView.tsx +++ b/components/calendar/CalendarDayView.tsx @@ -120,14 +120,14 @@ export function CalendarDayView({ {paidInvoices.map((invoice) => (
-
+
-
+
Faktura {invoice.invoice_number} - + Betald
@@ -151,18 +151,18 @@ export function CalendarDayView({ 'p-2 rounded-md', isDeadlineOverdue(deadline) ? 'bg-destructive/10 border border-destructive/30' - : 'bg-orange-500/10 border border-orange-500/30' + : 'bg-warning/10 border border-warning/30' )} >
{deadline.title} {isDeadlineOverdue(deadline) && ( @@ -197,17 +197,17 @@ export function CalendarDayView({ {completedDeadlines.map((deadline) => (
-
+
-
+
{deadline.title}
{DEADLINE_TYPE_LABELS[deadline.deadline_type] || deadline.deadline_type} - Klar + Klar
diff --git a/components/calendar/CalendarWeekView.tsx b/components/calendar/CalendarWeekView.tsx index e1c60300..eb02d9b7 100644 --- a/components/calendar/CalendarWeekView.tsx +++ b/components/calendar/CalendarWeekView.tsx @@ -114,7 +114,7 @@ export function CalendarWeekView({ 'text-xs p-1 mb-1 rounded truncate', isDeadlineOverdue(deadline) ? 'bg-destructive/20 text-destructive' - : 'bg-orange-500/20 text-orange-700' + : 'bg-warning/20 text-warning-foreground' )} title={deadline.title} > diff --git a/components/calendar/DeadlineCard.tsx b/components/calendar/DeadlineCard.tsx index a0ee75e2..18bea351 100644 --- a/components/calendar/DeadlineCard.tsx +++ b/components/calendar/DeadlineCard.tsx @@ -76,7 +76,7 @@ export function DeadlineCard({
)} diff --git a/components/calendar/PaymentSummaryCard.tsx b/components/calendar/PaymentSummaryCard.tsx index 7aaa1f4e..5dc9ddf7 100644 --- a/components/calendar/PaymentSummaryCard.tsx +++ b/components/calendar/PaymentSummaryCard.tsx @@ -26,8 +26,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard {/* Expected */}
-
- +
+

Förväntat

@@ -41,8 +41,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard {summary.overdueCount > 0 && (
-
- +
+

Förfallen

@@ -60,8 +60,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard {/* Paid */}
-
- +
+

Betald

@@ -77,8 +77,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard {summary.pendingCount > 0 && summary.overdueCount < summary.pendingCount && (
-
- +
+

Väntande

diff --git a/components/calendar/TaxTodoWidget.tsx b/components/calendar/TaxTodoWidget.tsx index 93209d0a..8295d3b6 100644 --- a/components/calendar/TaxTodoWidget.tsx +++ b/components/calendar/TaxTodoWidget.tsx @@ -112,11 +112,11 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps) } return ( - +
- + Att göra - Skatt
@@ -143,7 +143,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps) isOverdue ? 'border-destructive/50 bg-destructive/5' : isActionNeeded - ? 'border-orange-300 bg-orange-50' + ? 'border-warning/40 bg-warning/10' : 'border-border bg-background' }`} > @@ -152,7 +152,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps) {isOverdue ? ( ) : ( - + )}

{deadline.title}

diff --git a/components/chat/useChatStream.ts b/components/chat/useChatStream.ts index 00466903..5df6ed2a 100644 --- a/components/chat/useChatStream.ts +++ b/components/chat/useChatStream.ts @@ -60,7 +60,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream try { abortControllerRef.current = new AbortController() - const response = await fetch('/api/chat/stream', { + const response = await fetch('/api/extensions/ai-chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -175,7 +175,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream setError(null) try { - const response = await fetch(`/api/chat/sessions/${id}`) + const response = await fetch(`/api/extensions/ai-chat/sessions/${id}`) if (!response.ok) { throw new Error('Failed to load session') } diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 4d5876e3..5b4f15a8 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -5,13 +5,12 @@ import Link from 'next/link' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { formatCurrency } from '@/lib/utils' +import { cn, formatCurrency } from '@/lib/utils' import { calculateEFTax, calculateABTax, getEnhancedTaxWarningStatus } from '@/lib/tax/calculator' -import { getSchablonavdragSummary } from '@/lib/tax/schablonavdrag' import FSkattWarningCard from '@/components/dashboard/FSkattWarningCard' import { UpcomingDeadlinesWidget } from '@/components/calendar/UpcomingDeadlinesWidget' import NewUserChecklist from '@/components/onboarding/NewUserChecklist' @@ -23,11 +22,9 @@ import { ChevronDown, ArrowRight, Camera, - HelpCircle, Users, - FileText, } from 'lucide-react' -import type { CompanySettings, EntityType, MileageEntry, SchablonavdragSettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' +import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' interface DashboardContentProps { firstName?: string | null @@ -43,7 +40,6 @@ interface DashboardContentProps { unpaidVatTotal: number overdueInvoicesCount: number bankBalance: number | null - mileageEntries: MileageEntry[] deadlines: Deadline[] receiptQueue: ReceiptQueueSummary | null } @@ -58,19 +54,11 @@ export default function DashboardContent({ firstName, settings, summary, onboard const currentMonth = new Date().getMonth() + 1 const preliminaryTaxPaidYTD = preliminaryTaxMonthly * currentMonth - const schablonavdragSettings = (settings as CompanySettings & { schablonavdrag_settings?: SchablonavdragSettings })?.schablonavdrag_settings || null - const schablonavdragSummary = getSchablonavdragSummary( - schablonavdragSettings, - summary.mileageEntries, - new Date().getFullYear(), - currentMonth - ) - const totalTaxableIncome = summary.ytd.net const taxEstimate = entityType === 'enskild_firma' - ? calculateEFTax(totalTaxableIncome, preliminaryTaxPaidYTD, schablonavdragSummary, summary.unpaidVatTotal) + ? calculateEFTax(totalTaxableIncome, preliminaryTaxPaidYTD, null, summary.unpaidVatTotal) : calculateABTax(totalTaxableIncome, 0, preliminaryTaxPaidYTD, summary.unpaidVatTotal) const taxWarning = getEnhancedTaxWarningStatus(taxEstimate, preliminaryTaxMonthly, currentMonth) @@ -91,24 +79,19 @@ export default function DashboardContent({ firstName, settings, summary, onboard if (summary.overdueInvoicesCount > 0) { alertItems.push( - - -
-
-
- -
+ + +
+
+

Förfallna fakturor

-

- {summary.overdueInvoicesCount} förfallna +

+ {summary.overdueInvoicesCount} st

- - Kräver åtgärd -
- +
@@ -119,21 +102,19 @@ export default function DashboardContent({ firstName, settings, summary, onboard if (summary.unpaidInvoicesCount > 0 && summary.overdueInvoicesCount < summary.unpaidInvoicesCount) { alertItems.push( - - -
-
-
- -
+ + +
+
+

Obetalda fakturor

-

- {summary.unpaidInvoicesCount - summary.overdueInvoicesCount} obetalda · {formatCurrency(summary.unpaidInvoicesTotal)} +

+ {summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}

- +
@@ -144,21 +125,19 @@ export default function DashboardContent({ firstName, settings, summary, onboard if (summary.uncategorizedCount > 0) { alertItems.push( - - -
-
-
- -
+ + +
+
+

Transaktioner

-

+

{summary.uncategorizedCount} okategoriserade

- +
@@ -169,23 +148,21 @@ export default function DashboardContent({ firstName, settings, summary, onboard if (summary.receiptQueue && (summary.receiptQueue.pending_review_count > 0 || summary.receiptQueue.unmatched_receipts_count > 0)) { alertItems.push( - - -
-
-
- -
+ + +
+
+

Kvitton

-

+

{summary.receiptQueue.pending_review_count > 0 ? `${summary.receiptQueue.pending_review_count} att granska` : `${summary.receiptQueue.unmatched_receipts_count} omatchade`}

- +
@@ -207,34 +184,26 @@ export default function DashboardContent({ firstName, settings, summary, onboard return (
- {/* Hero section - personalized greeting */} -
-
-
-

- {(() => { - const hour = new Date().getHours() - if (hour < 12) return 'Godmorgon' - if (hour < 18) return 'God eftermiddag' - return 'God kväll' - })()}{firstName ? ` ${firstName}` : ''}{settings?.company_name ? `, ${settings.company_name}` : ''} -

-

- {summary.overdueInvoicesCount > 0 - ? `${summary.overdueInvoicesCount} förfallna fakturor kräver åtgärd` - : summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length > 0 - ? `${summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length} passerade deadlines` - : 'Allt är som det ska'} -

-
- - - Hjälp - -
+ {/* Header */} +
+

+ {(() => { + const hour = new Date().getHours() + if (hour < 12) return 'Godmorgon' + if (hour < 18) return 'God eftermiddag' + return 'God kväll' + })()}{firstName ? `, ${firstName}` : ''} +

+

+ {summary.overdueInvoicesCount > 0 + ? `${summary.overdueInvoicesCount} förfallna fakturor kräver åtgärd` + : summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length > 0 + ? `${summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length} passerade deadlines` + : 'Allt är som det ska'} +

- {/* TODO section - actionable items */} + {/* Status pills */} {(() => { const passedDeadlines = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()) const todoItems: { label: string; href: string; count: number; variant: 'destructive' | 'warning' | 'default' }[] = [] @@ -246,7 +215,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' }) } if (summary.uncategorizedCount > 0) { - todoItems.push({ label: 'okategoriserade transaktioner', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' }) + todoItems.push({ label: 'okategoriserade', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' }) } if (summary.receiptQueue && summary.receiptQueue.pending_review_count > 0) { todoItems.push({ label: 'kvitton att granska', href: '/receipts', count: summary.receiptQueue.pending_review_count, variant: 'default' }) @@ -255,13 +224,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard if (todoItems.length === 0) return null return ( -
-
+
+
{todoItems.map((item) => ( {item.count} {item.label} @@ -274,7 +243,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard {/* New user checklist */} {onboardingProgress && ( -
+
)} - {/* Upcoming deadlines - prominent placement */} + {/* Upcoming deadlines */} {summary.deadlines && summary.deadlines.length > 0 && ( -
+
)} - {/* Quick actions - moved up, as visual cards */} -
-

Snabbåtgärder

-
+ {/* Quick actions */} +
+

Snabbåtgärder

+
{quickActions.map((action) => { const Icon = action.icon return ( - - -
- -
-
-

{action.label}

-

{action.desc}

-
-
-
+
+
+ +
+
+

{action.label}

+

{action.desc}

+
+
) })} @@ -317,24 +295,24 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{/* F-skatt warning */} -
+
{ window.location.href = '/settings' }} />
- {/* Alerts section - with urgency indicators and limit */} + {/* Alerts section */} {alertItems.length > 0 && ( -
-

Att hantera

-
+
+

Att hantera

+
{visibleAlerts}
{hasMoreAlerts && !showAllAlerts && ( {isOvrigtExpanded && ( -
+
{övrigtItems.map((item) => { const Icon = item.icon const active = isActive(item.href) @@ -246,15 +192,15 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr key={item.href} href={item.href} className={cn( - 'group flex items-center px-3 py-2 text-sm transition-all duration-200 rounded-lg', + 'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg', active - ? 'bg-primary/10 text-primary font-medium' - : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50' + ? 'bg-primary/8 text-foreground font-medium' + : 'text-muted-foreground hover:text-foreground hover:bg-muted/40' )} > {item.label} @@ -267,13 +213,13 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
{/* Logout button */} -
+
@@ -286,23 +232,6 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr {mobileNavItems.map((item) => { const Icon = item.icon const active = isActive(item.href) - const isScan = 'isScan' in item && item.isScan - - if (isScan) { - return ( - -
- -
- {item.label} - - ) - } return ( - {/* Moduler section */} -
-

- Företagsmoduler -

- {modulerItems.map((item) => { - const Icon = item.icon - const active = isActive(item.href) - return ( - - - {item.label} - - ) - })} -
- {/* Other section */}

diff --git a/components/dashboard/FSkattWarningCard.tsx b/components/dashboard/FSkattWarningCard.tsx index d13a1397..c33e64bb 100644 --- a/components/dashboard/FSkattWarningCard.tsx +++ b/components/dashboard/FSkattWarningCard.tsx @@ -44,9 +44,9 @@ export default function FSkattWarningCard({ }, info: { icon: Info, - iconColor: 'text-blue-500', - bgColor: 'bg-blue-50 dark:bg-blue-950/20', - borderColor: 'border-blue-200 dark:border-blue-800', + iconColor: 'text-primary', + bgColor: 'bg-primary/5', + borderColor: 'border-primary/20', badgeVariant: 'outline' as const, }, warning: { diff --git a/components/modules/ModuleToggle.tsx b/components/modules/ModuleToggle.tsx deleted file mode 100644 index c8b5145f..00000000 --- a/components/modules/ModuleToggle.tsx +++ /dev/null @@ -1,87 +0,0 @@ -'use client' - -import { useEffect, useState, useTransition } from 'react' -import { createClient } from '@/lib/supabase/client' -import { Switch } from '@/components/ui/switch' -import { Loader2 } from 'lucide-react' - -interface ModuleToggleProps { - sectorSlug: string - moduleSlug: string -} - -export function ModuleToggle({ sectorSlug, moduleSlug }: ModuleToggleProps) { - const [enabled, setEnabled] = useState(false) - const [loading, setLoading] = useState(true) - const [isPending, startTransition] = useTransition() - const supabase = createClient() - - useEffect(() => { - async function fetchToggle() { - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - setLoading(false) - return - } - - const { data } = await supabase - .from('module_toggles') - .select('enabled') - .eq('user_id', user.id) - .eq('sector_slug', sectorSlug) - .eq('module_slug', moduleSlug) - .maybeSingle() - - setEnabled(data?.enabled ?? false) - setLoading(false) - } - - fetchToggle() - }, [sectorSlug, moduleSlug, supabase]) - - function handleToggle(checked: boolean) { - setEnabled(checked) - - startTransition(async () => { - const { data: { user } } = await supabase.auth.getUser() - if (!user) return - - await supabase - .from('module_toggles') - .upsert( - { - user_id: user.id, - sector_slug: sectorSlug, - module_slug: moduleSlug, - enabled: checked, - }, - { onConflict: 'user_id,sector_slug,module_slug' } - ) - }) - } - - if (loading) { - return ( -

- - Laddar… -
- ) - } - - return ( - - ) -} diff --git a/components/settings/SchablonavdragSettings.tsx b/components/settings/SchablonavdragSettings.tsx deleted file mode 100644 index 606ba5e2..00000000 --- a/components/settings/SchablonavdragSettings.tsx +++ /dev/null @@ -1,204 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Switch } from '@/components/ui/switch' -import { Label } from '@/components/ui/label' -import { Button } from '@/components/ui/button' -import { useToast } from '@/components/ui/use-toast' -import { formatCurrency } from '@/lib/utils' -import { SCHABLONAVDRAG_RATES, type HousingType } from '@/lib/tax/schablonavdrag' -import { Home, Car, Loader2, Info, Building } from 'lucide-react' -import type { SchablonavdragSettings as SchablonavdragSettingsType } from '@/types' - -interface SchablonavdragSettingsProps { - settings: SchablonavdragSettingsType - onSave: (settings: SchablonavdragSettingsType) => Promise -} - -export default function SchablonavdragSettings({ - settings: initialSettings, - onSave, -}: SchablonavdragSettingsProps) { - const { toast } = useToast() - const [isSaving, setIsSaving] = useState(false) - const [settings, setSettings] = useState(initialSettings) - - const hasChanges = - settings.hemmakontor_enabled !== initialSettings.hemmakontor_enabled || - settings.hemmakontor_housing_type !== initialSettings.hemmakontor_housing_type || - settings.bil_enabled !== initialSettings.bil_enabled - - // Get the current hemmakontor amount based on housing type - const housingType = settings.hemmakontor_housing_type || 'apartment' - const hemmakontorAmount = housingType === 'villa' - ? SCHABLONAVDRAG_RATES.hemmakontor.villa - : SCHABLONAVDRAG_RATES.hemmakontor.apartment - - async function handleSave() { - setIsSaving(true) - try { - await onSave(settings) - toast({ - title: 'Sparat', - description: 'Dina schablonavdragsinställningar har uppdaterats', - }) - } catch (error) { - toast({ - title: 'Fel', - description: 'Kunde inte spara inställningar', - variant: 'destructive', - }) - } - setIsSaving(false) - } - - function setHousingType(type: HousingType) { - setSettings({ ...settings, hemmakontor_housing_type: type }) - } - - return ( - - - - Schablonavdrag - - - Aktivera schablonmässiga avdrag som inte kräver kvitton - - - - {/* Hemmakontor */} -
-
-
-
- -
-
- -

- Schablonavdrag för hemmakontor (minst 800 tim/år) -

-
- - {formatCurrency(hemmakontorAmount)} - - per år -
-
-
- - setSettings({ ...settings, hemmakontor_enabled: checked }) - } - /> -
- - {/* Housing type selection */} - {settings.hemmakontor_enabled && ( -
- -
- - -
-
- )} -
- - {/* Bilkostnader */} -
-
-
- -
-
- -

- Milersättning för tjänsteresor med egen bil -

-
- - {SCHABLONAVDRAG_RATES.bil.rate_per_mil} kr/mil - - - ({SCHABLONAVDRAG_RATES.bil.rate_per_km.toFixed(2)} kr/km) - -
-
-
- - setSettings({ ...settings, bil_enabled: checked }) - } - /> -
- - {/* Info box */} -
- -
-

Om schablonavdrag

-
    -
  • - Hemmakontor: Kräver minst 800 timmars arbete hemifrån per år. - Avdrag: {formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.apartment)} (lägenhet) eller{' '} - {formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.villa)} (villa). -
  • -
  • - Bilkostnader: Kräver att du loggar dina resor i körjournalen. - Du får {SCHABLONAVDRAG_RATES.bil.rate_per_mil} kr per mil ({SCHABLONAVDRAG_RATES.bil.rate_per_km.toFixed(2)} kr/km). -
  • -
  • Schablonavdrag minskar din beskattningsbara inkomst
  • -
-
-
- - {/* Save button */} - {hasChanges && ( -
- -
- )} -
-
- ) -} diff --git a/components/transactions/MileageEntry.tsx b/components/transactions/MileageEntry.tsx deleted file mode 100644 index 3c455175..00000000 --- a/components/transactions/MileageEntry.tsx +++ /dev/null @@ -1,242 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Card, CardContent, CardDescription, 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 { Textarea } from '@/components/ui/textarea' -import { useToast } from '@/components/ui/use-toast' -import { formatCurrency } from '@/lib/utils' -import { SCHABLONAVDRAG_RATES, validateMileageEntry } from '@/lib/tax/schablonavdrag' -import { Car, Loader2, MapPin, Calendar, FileText, X } from 'lucide-react' -import type { CreateMileageEntryInput } from '@/types' - -interface MileageEntryProps { - onSave: (entry: CreateMileageEntryInput) => Promise - onCancel?: () => void - initialDate?: string -} - -export default function MileageEntry({ - onSave, - onCancel, - initialDate, -}: MileageEntryProps) { - const { toast } = useToast() - const [isSaving, setIsSaving] = useState(false) - const [errors, setErrors] = useState([]) - - const today = new Date().toISOString().split('T')[0] - const [formData, setFormData] = useState({ - date: initialDate || today, - distance_km: 0, - purpose: '', - from_location: '', - to_location: '', - }) - - // Calculate deduction preview - const deductionPreview = formData.distance_km > 0 - ? formData.distance_km * SCHABLONAVDRAG_RATES.bil.rate_per_km - : 0 - - function handleChange( - field: keyof CreateMileageEntryInput, - value: string | number - ) { - setFormData((prev) => ({ ...prev, [field]: value })) - setErrors([]) // Clear errors on change - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - - // Validate - const validation = validateMileageEntry({ - date: formData.date, - distance_km: formData.distance_km, - purpose: formData.purpose, - }) - - if (!validation.valid) { - setErrors(validation.errors) - return - } - - setIsSaving(true) - try { - await onSave(formData) - toast({ - title: 'Körning registrerad', - description: `${formData.distance_km} km - avdrag ${formatCurrency(deductionPreview)}`, - }) - // Reset form - setFormData({ - date: today, - distance_km: 0, - purpose: '', - from_location: '', - to_location: '', - }) - } catch (error) { - toast({ - title: 'Fel', - description: 'Kunde inte spara körningen', - variant: 'destructive', - }) - } - setIsSaving(false) - } - - return ( - - -
-
- - - Registrera körning - - - Logga en tjänsteresa för milersättning - -
- {onCancel && ( - - )} -
-
- -
- {/* Error display */} - {errors.length > 0 && ( -
-
    - {errors.map((error, idx) => ( -
  • {error}
  • - ))} -
-
- )} - - {/* Date and distance row */} -
-
- - handleChange('date', e.target.value)} - required - /> -
-
- - - handleChange('distance_km', parseFloat(e.target.value) || 0) - } - required - /> -
-
- - {/* Locations row */} -
-
- - handleChange('from_location', e.target.value)} - /> -
-
- - handleChange('to_location', e.target.value)} - /> -
-
- - {/* Purpose */} -
- -