From 6a5b2b7792b9061eb4165ca6985de29e9c2ccb74 Mon Sep 17 00:00:00 2001 From: Emil Date: Sat, 21 Feb 2026 15:21:49 +0100 Subject: [PATCH] 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 --- app/(dashboard)/settings/page.tsx | 112 ++ app/api/bookkeeping/account-totals/route.ts | 132 +++ .../extensions/[sector]/[slug]/data/route.ts | 4 + app/globals.css | 78 +- app/layout.tsx | 15 +- components/dashboard/DashboardNav.tsx | 59 +- components/extensions/CategoryBadge.tsx | 8 +- .../construction/ProjectCostWorkspace.tsx | 866 +++++++++++++- .../construction/RotCalculatorWorkspace.tsx | 615 +++++++++- .../MultichannelRevenueWorkspace.tsx | 991 +++++++++++++++- .../ecommerce/ShopifyImportWorkspace.tsx | 877 +++++++++++++- .../extensions/hotel/OccupancyWorkspace.tsx | 580 +++++++++- .../extensions/hotel/RevparWorkspace.tsx | 590 +++++++++- .../restaurant/EarningsPerLiterWorkspace.tsx | 848 ++++++++++++-- .../restaurant/FoodCostWorkspace.tsx | 542 ++++++++- .../restaurant/PosImportWorkspace.tsx | 698 +++++++++++- .../restaurant/TipTrackingWorkspace.tsx | 846 +++++++++++++- .../extensions/shared/ConfirmDeleteDialog.tsx | 63 ++ .../extensions/shared/CsvImportWizard.tsx | 231 ++++ .../extensions/shared/EditEntryDialog.tsx | 62 + .../extensions/shared/MonthlyTrendTable.tsx | 74 ++ components/extensions/shared/SetupPrompt.tsx | 72 ++ .../tech/BillableHoursWorkspace.tsx | 967 +++++++++++++++- .../tech/ProjectBillingWorkspace.tsx | 1004 ++++++++++++++++- components/theme-provider.tsx | 11 + components/ui/card.tsx | 2 +- .../__tests__/project-cost-calculator.test.ts | 186 +++ .../lib/project-cost-calculator.ts | 133 +++ .../lib/__tests__/rot-calculator.test.ts | 164 +++ .../rot-calculator/lib/rot-calculator.ts | 118 ++ .../__tests__/multichannel-calculator.test.ts | 211 ++++ .../lib/multichannel-calculator.ts | 200 ++++ .../lib/__tests__/shopify-calculator.test.ts | 167 +++ .../shopify-import/lib/shopify-calculator.ts | 199 ++++ .../__tests__/occupancy-calculator.test.ts | 178 +++ .../occupancy/lib/occupancy-calculator.ts | 151 +++ .../lib/__tests__/revpar-calculator.test.ts | 227 ++++ .../hotel/revpar/lib/revpar-calculator.ts | 166 +++ .../lib/__tests__/pos-calculator.test.ts | 225 ++++ .../pos-import/lib/pos-calculator.ts | 183 +++ .../lib/__tests__/tip-calculator.test.ts | 196 ++++ .../tip-tracking/lib/tip-calculator.ts | 180 +++ .../billable-hours-calculator.test.ts | 193 ++++ .../lib/billable-hours-calculator.ts | 190 ++++ .../lib/__tests__/billing-calculator.test.ts | 206 ++++ .../project-billing/lib/billing-calculator.ts | 107 ++ lib/extensions/__tests__/validation.test.ts | 177 +++ lib/extensions/use-account-totals.ts | 80 ++ lib/extensions/use-extension-data.ts | 86 ++ lib/extensions/validation.ts | 70 ++ package-lock.json | 11 + package.json | 1 + 52 files changed, 14130 insertions(+), 222 deletions(-) create mode 100644 app/api/bookkeeping/account-totals/route.ts create mode 100644 components/extensions/shared/ConfirmDeleteDialog.tsx create mode 100644 components/extensions/shared/CsvImportWizard.tsx create mode 100644 components/extensions/shared/EditEntryDialog.tsx create mode 100644 components/extensions/shared/MonthlyTrendTable.tsx create mode 100644 components/extensions/shared/SetupPrompt.tsx create mode 100644 components/theme-provider.tsx create mode 100644 extensions/construction/project-cost/lib/__tests__/project-cost-calculator.test.ts create mode 100644 extensions/construction/project-cost/lib/project-cost-calculator.ts create mode 100644 extensions/construction/rot-calculator/lib/__tests__/rot-calculator.test.ts create mode 100644 extensions/construction/rot-calculator/lib/rot-calculator.ts create mode 100644 extensions/ecommerce/multichannel-revenue/lib/__tests__/multichannel-calculator.test.ts create mode 100644 extensions/ecommerce/multichannel-revenue/lib/multichannel-calculator.ts create mode 100644 extensions/ecommerce/shopify-import/lib/__tests__/shopify-calculator.test.ts create mode 100644 extensions/ecommerce/shopify-import/lib/shopify-calculator.ts create mode 100644 extensions/hotel/occupancy/lib/__tests__/occupancy-calculator.test.ts create mode 100644 extensions/hotel/occupancy/lib/occupancy-calculator.ts create mode 100644 extensions/hotel/revpar/lib/__tests__/revpar-calculator.test.ts create mode 100644 extensions/hotel/revpar/lib/revpar-calculator.ts create mode 100644 extensions/restaurant/pos-import/lib/__tests__/pos-calculator.test.ts create mode 100644 extensions/restaurant/pos-import/lib/pos-calculator.ts create mode 100644 extensions/restaurant/tip-tracking/lib/__tests__/tip-calculator.test.ts create mode 100644 extensions/restaurant/tip-tracking/lib/tip-calculator.ts create mode 100644 extensions/tech/billable-hours/lib/__tests__/billable-hours-calculator.test.ts create mode 100644 extensions/tech/billable-hours/lib/billable-hours-calculator.ts create mode 100644 extensions/tech/project-billing/lib/__tests__/billing-calculator.test.ts create mode 100644 extensions/tech/project-billing/lib/billing-calculator.ts create mode 100644 lib/extensions/__tests__/validation.test.ts create mode 100644 lib/extensions/use-account-totals.ts create mode 100644 lib/extensions/use-extension-data.ts create mode 100644 lib/extensions/validation.ts 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} + +