diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index a837df74..af501a07 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -22,7 +22,12 @@ import { LogOut, Bell, Calendar, + Sun, + Moon, + Monitor, + Palette, } from 'lucide-react' +import { useTheme } from 'next-themes' import type { CompanySettings, BankConnection } from '@/types' import { NotificationSettings } from '@/extensions/general/push-notifications/NotificationSettings' import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings' @@ -40,6 +45,12 @@ export default function SettingsPage() { const [isSyncing, setIsSyncing] = useState(false) const [isConnecting, setIsConnecting] = useState(false) const [hasBankingExtension, setHasBankingExtension] = useState(false) + const { theme, setTheme } = useTheme() + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) useEffect(() => { fetchData() @@ -309,6 +320,10 @@ export default function SettingsPage() { Kalender + + + Utseende + Konto @@ -600,6 +615,103 @@ export default function SettingsPage() { + {/* Appearance settings */} + + + + Utseende + + Välj hur applikationen ska se ut + + + + {mounted && ( +
+ {/* Light */} + + + {/* Dark */} + + + {/* System */} + +
+ )} +
+
+
+ {/* Account settings */} diff --git a/app/api/bookkeeping/account-totals/route.ts b/app/api/bookkeeping/account-totals/route.ts new file mode 100644 index 00000000..c13749b8 --- /dev/null +++ b/app/api/bookkeeping/account-totals/route.ts @@ -0,0 +1,132 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' + +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 from = searchParams.get('from') + const to = searchParams.get('to') + const dateFrom = searchParams.get('date_from') + const dateTo = searchParams.get('date_to') + const groupBy = searchParams.get('group_by') + + if (!from || !to) { + return NextResponse.json( + { error: 'from and to account numbers are required' }, + { status: 400 } + ) + } + + // Get posted journal entries within date range + let entriesQuery = supabase + .from('journal_entries') + .select('id, entry_date') + .eq('user_id', user.id) + .eq('status', 'posted') + + if (dateFrom) { + entriesQuery = entriesQuery.gte('entry_date', dateFrom) + } + if (dateTo) { + entriesQuery = entriesQuery.lte('entry_date', dateTo) + } + + const { data: entries, error: entriesError } = await entriesQuery + + if (entriesError) { + return NextResponse.json({ error: entriesError.message }, { status: 500 }) + } + + if (!entries || entries.length === 0) { + return NextResponse.json({ totals: [], monthly: groupBy === 'month' ? [] : undefined }) + } + + const entryIds = entries.map((e) => e.id) + const entryDateMap = new Map(entries.map((e) => [e.id, e.entry_date])) + + // Fetch lines in batches to avoid URL length limits + const batchSize = 200 + const allLines: Array<{ + journal_entry_id: string + account_number: string + debit_amount: number + credit_amount: number + }> = [] + + for (let i = 0; i < entryIds.length; i += batchSize) { + const batch = entryIds.slice(i, i + batchSize) + const { data: lines, error: linesError } = await supabase + .from('journal_entry_lines') + .select('journal_entry_id, account_number, debit_amount, credit_amount') + .in('journal_entry_id', batch) + .gte('account_number', from) + .lte('account_number', to) + + if (linesError) { + return NextResponse.json({ error: linesError.message }, { status: 500 }) + } + if (lines) { + allLines.push(...lines) + } + } + + // Aggregate by account + const accountTotals = new Map() + + for (const line of allLines) { + const existing = accountTotals.get(line.account_number) || { debit: 0, credit: 0 } + existing.debit += Number(line.debit_amount) || 0 + existing.credit += Number(line.credit_amount) || 0 + accountTotals.set(line.account_number, existing) + } + + const totals = Array.from(accountTotals.entries()) + .map(([account_number, bal]) => ({ + account_number, + debit: Math.round(bal.debit * 100) / 100, + credit: Math.round(bal.credit * 100) / 100, + net: Math.round((bal.debit - bal.credit) * 100) / 100, + })) + .sort((a, b) => a.account_number.localeCompare(b.account_number)) + + // Monthly grouping + if (groupBy === 'month') { + const monthlyMap = new Map>() + + for (const line of allLines) { + const entryDate = entryDateMap.get(line.journal_entry_id) + if (!entryDate) continue + const month = entryDate.slice(0, 7) + if (!monthlyMap.has(month)) { + monthlyMap.set(month, new Map()) + } + const monthAccounts = monthlyMap.get(month)! + const existing = monthAccounts.get(line.account_number) || { debit: 0, credit: 0 } + existing.debit += Number(line.debit_amount) || 0 + existing.credit += Number(line.credit_amount) || 0 + monthAccounts.set(line.account_number, existing) + } + + const monthlyFlat = Array.from(monthlyMap.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .flatMap(([month, accounts]) => + Array.from(accounts.entries()).map(([account_number, bal]) => ({ + month, + account_number, + debit: Math.round(bal.debit * 100) / 100, + credit: Math.round(bal.credit * 100) / 100, + net: Math.round((bal.debit - bal.credit) * 100) / 100, + })) + ) + + return NextResponse.json({ totals, monthly: monthlyFlat }) + } + + return NextResponse.json({ totals }) +} diff --git a/app/api/extensions/[sector]/[slug]/data/route.ts b/app/api/extensions/[sector]/[slug]/data/route.ts index f9d346c8..e0b0cb95 100644 --- a/app/api/extensions/[sector]/[slug]/data/route.ts +++ b/app/api/extensions/[sector]/[slug]/data/route.ts @@ -24,8 +24,12 @@ export async function GET( .eq('user_id', user.id) .eq('extension_id', extensionId) + const prefix = searchParams.get('prefix') + if (key) { query = query.eq('key', key) + } else if (prefix) { + query = query.ilike('key', `${prefix}%`) } const { data, error } = await query diff --git a/app/globals.css b/app/globals.css index 67204d5d..0f8fe092 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,36 +1,9 @@ @import "tailwindcss"; @plugin "@tailwindcss/typography"; - -/* Force light mode */ -html, :root { - color-scheme: light only !important; -} - -@media (prefers-color-scheme: dark) { - :root { - --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; - } -} +@custom-variant dark (&:where(.dark, .dark *)); :root { + color-scheme: light; /* Clean Slate — Modern ERP Palette */ --background: 220 14% 96%; /* Cool off-white #F2F4F7 */ @@ -95,6 +68,48 @@ html, :root { --duration-slow: 500ms; } +.dark { + color-scheme: dark; + + --background: 222 16% 10%; + --foreground: 216 12% 90%; + + --card: 222 14% 13%; + --card-foreground: 216 12% 90%; + + --popover: 222 14% 13%; + --popover-foreground: 216 12% 90%; + + --primary: 222 50% 55%; + --primary-foreground: 0 0% 100%; + + --secondary: 220 14% 18%; + --secondary-foreground: 216 12% 90%; + + --muted: 220 12% 20%; + --muted-foreground: 218 8% 55%; + + --accent: 213 72% 58%; + --accent-foreground: 0 0% 100%; + + --destructive: 0 62% 55%; + --destructive-foreground: 0 0% 100%; + + --border: 220 12% 22%; + --input: 220 12% 22%; + --ring: 222 50% 55%; + + --success: 152 44% 45%; + --success-foreground: 0 0% 100%; + + --warning: 38 85% 55%; + --warning-foreground: 224 12% 13%; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45); +} + @theme inline { --color-background: hsl(var(--background)); --color-foreground: hsl(var(--foreground)); @@ -283,3 +298,8 @@ h1, h2, h3 { background: hsl(var(--primary) / 0.15); color: hsl(var(--foreground)); } + +.dark ::selection { + background: hsl(var(--primary) / 0.3); + color: hsl(var(--foreground)); +} diff --git a/app/layout.tsx b/app/layout.tsx index 56d6faa9..535288f0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { Fraunces } from "next/font/google"; import Script from "next/script"; import { Toaster } from "@/components/ui/toaster"; +import { ThemeProvider } from "@/components/theme-provider"; import "./globals.css"; const geistSans = Geist({ @@ -44,16 +45,22 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} - + + {children} + +