feat: overhaul all 12 sector extensions with full CRUD, validation, and tests
Add shared components (ConfirmDeleteDialog, EditEntryDialog, validation utils), enhance all 12 extension workspaces with edit/delete dialogs, input validation, period comparisons, and new analytics features. Fix critical bugs in ProjectBilling margin calculation and EarningsPerLiter revenue allocation. Add pure calculation modules with 183 new tests across all extensions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Kalender
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="appearance">
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
Utseende
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="account">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Konto
|
||||
@@ -600,6 +615,103 @@ export default function SettingsPage() {
|
||||
<CalendarFeedSettings />
|
||||
</TabsContent>
|
||||
|
||||
{/* Appearance settings */}
|
||||
<TabsContent value="appearance">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Utseende</CardTitle>
|
||||
<CardDescription>
|
||||
Välj hur applikationen ska se ut
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mounted && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Light */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme('light')}
|
||||
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
|
||||
theme === 'light'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex h-20 items-end gap-1.5 rounded-md border bg-white p-2">
|
||||
<div className="h-full w-3 rounded-sm bg-[hsl(222,47%,35%)]" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
<div className="h-2 w-2/3 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Ljust</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Dark */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
|
||||
theme === 'dark'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex h-20 items-end gap-1.5 rounded-md border bg-[hsl(222,16%,10%)] p-2">
|
||||
<div className="h-full w-3 rounded-sm bg-[hsl(222,50%,55%)]" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
<div className="h-2 w-2/3 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Moon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Mörkt</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* System */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme('system')}
|
||||
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
|
||||
theme === 'system'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex h-20 overflow-hidden rounded-md border">
|
||||
<div className="flex flex-1 items-end gap-1 bg-white p-2">
|
||||
<div className="h-full w-2 rounded-sm bg-[hsl(222,47%,35%)]" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 items-end gap-1 bg-[hsl(222,16%,10%)] p-2">
|
||||
<div className="h-full w-2 rounded-sm bg-[hsl(222,50%,55%)]" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">System</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Account settings */}
|
||||
<TabsContent value="account">
|
||||
<Card>
|
||||
|
||||
@@ -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<string, { debit: number; credit: number }>()
|
||||
|
||||
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<string, Map<string, { debit: number; credit: number }>>()
|
||||
|
||||
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 })
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+49
-29
@@ -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));
|
||||
}
|
||||
|
||||
+11
-4
@@ -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 (
|
||||
<html lang="sv">
|
||||
<html lang="sv" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${fraunces.variable} antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
<Script src="/sw-register.js" strategy="afterInteractive" />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user