From c53f2e6c4d27a01a6702711ce47603fe851a9296 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Fri, 26 Jun 2026 03:08:22 -0400 Subject: [PATCH] Unify the dashboard experience and retire the multi-theme engine so onboarding and day-to-day invoicing feel consistent and easier to maintain. Shared layout, tabs, and sidebar timer; user onboarding and registration polish; settings danger zone and data export; chart and tRPC perf fixes; migrations for onboarding and dropped appearance columns. Co-authored-by: Cursor --- .env.example | 16 +- README.md | 2 + drizzle/0017_drop_theme_engine_columns.sql | 39 + drizzle/0018_user_onboarding.sql | 11 + drizzle/meta/_journal.json | 14 + src/app/api/auth/forgot-password/route.ts | 8 +- src/app/api/auth/register/route.ts | 7 + src/app/api/mcp/route.ts | 3 +- src/app/auth/register/page.tsx | 212 +---- src/app/auth/register/register-form.tsx | 201 +++++ .../_components/active-timer-widget.tsx | 228 +++-- .../_components/animated-stats-card.tsx | 60 +- .../_components/invoice-status-chart.tsx | 74 +- .../_components/monthly-metrics-chart.tsx | 133 +-- .../dashboard/_components/revenue-chart.tsx | 21 +- src/app/dashboard/administration/page.tsx | 28 +- src/app/dashboard/businesses/[id]/page.tsx | 21 +- src/app/dashboard/clients/[id]/page.tsx | 21 +- .../entities/_components/entities-view.tsx | 62 +- src/app/dashboard/entities/page.tsx | 25 +- src/app/dashboard/expenses/page.tsx | 15 +- .../_components/invoice-details-skeleton.tsx | 26 +- .../[id]/_components/pdf-download-button.tsx | 12 +- src/app/dashboard/invoices/[id]/page.tsx | 23 +- src/app/dashboard/invoices/[id]/send/page.tsx | 78 +- src/app/dashboard/invoices/import/page.tsx | 15 +- src/app/dashboard/invoices/page.tsx | 12 +- src/app/dashboard/invoices/recurring/page.tsx | 12 +- src/app/dashboard/invoices/templates/page.tsx | 42 +- src/app/dashboard/layout.tsx | 19 +- .../_components/onboarding-wizard.tsx | 284 +++++++ src/app/dashboard/onboarding/page.tsx | 10 + src/app/dashboard/page.tsx | 498 +++++------ src/app/dashboard/reports/page.tsx | 56 +- .../settings/_components/settings-content.tsx | 723 +++------------- src/app/dashboard/settings/page.tsx | 10 +- src/app/dashboard/time-clock/page.tsx | 5 +- src/app/layout.tsx | 56 +- src/components/branding/logo.tsx | 16 +- src/components/charts/responsive-chart.tsx | 25 + src/components/forms/business-form.tsx | 23 +- src/components/forms/client-form.tsx | 23 +- src/components/forms/email-preview.tsx | 6 +- src/components/forms/invoice-form.tsx | 136 ++- src/components/layout/dashboard-page.tsx | 59 ++ src/components/layout/dashboard-shell.tsx | 49 +- .../layout/dashboard-user-context.tsx | 29 + src/components/layout/floating-action-bar.tsx | 13 +- src/components/layout/onboarding-guard.tsx | 24 + src/components/layout/page-header.tsx | 8 +- src/components/layout/page-tabs.tsx | 75 ++ src/components/layout/sidebar.tsx | 17 +- src/components/marketing/app-screenshots.tsx | 9 +- src/components/marketing/browser-frame.tsx | 3 +- src/components/navigation/sidebar-trigger.tsx | 7 +- .../animation-preferences-provider-synced.tsx | 10 +- .../providers/appearance-provider-synced.tsx | 254 ++---- .../providers/appearance-provider.tsx | 192 +---- src/components/ui/tabs.tsx | 4 +- src/env.js | 34 - src/lib/app-email.ts | 6 + src/lib/app-url.ts | 20 + src/lib/appearance.ts | 106 +-- src/lib/auth-client.ts | 10 +- src/lib/branding.ts | 301 +------ src/lib/db-errors.ts | 44 + src/lib/email-templates/invoice-email.ts | 4 +- .../email-templates/password-reset-email.ts | 5 +- src/lib/legal.ts | 9 +- src/lib/navigation.ts | 13 + src/lib/pluralize.ts | 24 +- src/server/api/routers/dashboard.ts | 367 +++++--- src/server/api/routers/email.ts | 6 +- src/server/api/routers/invoices.ts | 3 +- src/server/api/routers/settings.ts | 801 +++++++++++++----- src/server/api/trpc.ts | 11 +- src/server/db/schema.ts | 26 +- src/styles/globals.css | 588 +------------ src/trpc/react.tsx | 5 +- 79 files changed, 2871 insertions(+), 3576 deletions(-) create mode 100644 drizzle/0017_drop_theme_engine_columns.sql create mode 100644 drizzle/0018_user_onboarding.sql create mode 100644 src/app/auth/register/register-form.tsx create mode 100644 src/app/dashboard/onboarding/_components/onboarding-wizard.tsx create mode 100644 src/app/dashboard/onboarding/page.tsx create mode 100644 src/components/charts/responsive-chart.tsx create mode 100644 src/components/layout/dashboard-page.tsx create mode 100644 src/components/layout/dashboard-user-context.tsx create mode 100644 src/components/layout/onboarding-guard.tsx create mode 100644 src/components/layout/page-tabs.tsx create mode 100644 src/lib/app-email.ts create mode 100644 src/lib/app-url.ts create mode 100644 src/lib/db-errors.ts diff --git a/.env.example b/.env.example index 7c751d5..b2159b5 100644 --- a/.env.example +++ b/.env.example @@ -85,27 +85,13 @@ POSTGRES_DB=postgres # White-label defaults (optional) # ============================================================================= # Baked in at Docker build. After first deploy, admins can override many of -# these from Settings → Appearance in the dashboard. +# Optional white-label defaults (build-time). Users choose light/dark in Settings. NEXT_PUBLIC_BRAND_NAME=beenvoice NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice NEXT_PUBLIC_BRAND_ICON=$ -# Interface theme: beenvoice | frutiger | frutiger-aero | shadcn | minimal | editorial -NEXT_PUBLIC_DEFAULT_INTERFACE_THEME=beenvoice - -# Font prefs: brand | frutiger | platform | inter | serif -NEXT_PUBLIC_DEFAULT_FONT=brand -NEXT_PUBLIC_DEFAULT_BODY_FONT=brand -NEXT_PUBLIC_DEFAULT_HEADING_FONT=brand - -# Corner radius: none | sm | md | lg | xl -NEXT_PUBLIC_DEFAULT_RADIUS=xl - -# Sidebar chrome: floating | docked -NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE=floating - # ============================================================================= # Email — Resend (optional) # ============================================================================= diff --git a/README.md b/README.md index 97183b4..034cb77 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ Start Postgres (dev compose exposes port 5432): docker compose -f docker-compose.dev.yml up -d ``` +After a fresh volume (`docker compose down -v`), Postgres starts empty — you must apply schema before registering or signing in. + Apply schema (pick one): ```bash diff --git a/drizzle/0017_drop_theme_engine_columns.sql b/drizzle/0017_drop_theme_engine_columns.sql new file mode 100644 index 0000000..598425c --- /dev/null +++ b/drizzle/0017_drop_theme_engine_columns.sql @@ -0,0 +1,39 @@ +ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "colorTheme"; +--> statement-breakpoint +ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "customColor"; +--> statement-breakpoint +ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "interfaceTheme"; +--> statement-breakpoint +ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "fontPreference"; +--> statement-breakpoint +ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "bodyFontPreference"; +--> statement-breakpoint +ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "headingFontPreference"; +--> statement-breakpoint +ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "radiusPreference"; +--> statement-breakpoint +ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "sidebarStyle"; +--> statement-breakpoint +ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandName"; +--> statement-breakpoint +ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandTagline"; +--> statement-breakpoint +ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandLogoText"; +--> statement-breakpoint +ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandIcon"; +--> statement-breakpoint +ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "colorTheme"; +--> statement-breakpoint +ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "customColor"; +--> statement-breakpoint +ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "theme"; +--> statement-breakpoint +ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "interfaceTheme"; +--> statement-breakpoint +ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "bodyFontPreference"; +--> statement-breakpoint +ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "headingFontPreference"; +--> statement-breakpoint +ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "radiusPreference"; +--> statement-breakpoint +ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "sidebarStyle"; diff --git a/drizzle/0018_user_onboarding.sql b/drizzle/0018_user_onboarding.sql new file mode 100644 index 0000000..284e192 --- /dev/null +++ b/drizzle/0018_user_onboarding.sql @@ -0,0 +1,11 @@ +ALTER TABLE "beenvoice_user" ADD COLUMN IF NOT EXISTS "onboardingCompletedAt" timestamp; + +-- Users who already have a business are treated as onboarded +UPDATE "beenvoice_user" u +SET "onboardingCompletedAt" = COALESCE(u."onboardingCompletedAt", NOW()) +WHERE u."onboardingCompletedAt" IS NULL + AND EXISTS ( + SELECT 1 + FROM "beenvoice_business" b + WHERE b."createdById" = u."id" + ); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index aa5c9ee..32fe057 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -120,6 +120,20 @@ "when": 1781500000000, "tag": "0016_fix_send_reminder_at_column", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1781600000000, + "tag": "0017_drop_theme_engine_columns", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1781700000000, + "tag": "0018_user_onboarding", + "breakpoints": true } ] } diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts index 330e0a0..6ced93e 100644 --- a/src/app/api/auth/forgot-password/route.ts +++ b/src/app/api/auth/forgot-password/route.ts @@ -4,6 +4,8 @@ import { db } from "~/server/db"; import { users } from "~/server/db/schema"; import { Resend } from "resend"; import { env } from "~/env"; +import { APP_EMAIL_DOMAIN } from "~/lib/app-email"; +import { getAppUrl } from "~/lib/app-url"; import { generatePasswordResetEmailTemplate } from "~/lib/email-templates"; import crypto from "crypto"; @@ -72,7 +74,7 @@ export async function POST(request: NextRequest) { // Send password reset email using Resend try { const resend = new Resend(env.RESEND_API_KEY); - const resetUrl = `${process.env.BETTER_AUTH_URL ?? "http://localhost:3000"}/auth/reset-password?token=${resetToken}`; + const resetUrl = `${getAppUrl()}/auth/reset-password?token=${resetToken}`; const emailTemplate = generatePasswordResetEmailTemplate({ userEmail: email, @@ -82,8 +84,10 @@ export async function POST(request: NextRequest) { expiryHours: 24, }); + const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN; + await resend.emails.send({ - from: "beenvoice ", + from: `beenvoice `, to: email, subject: emailTemplate.subject, html: emailTemplate.html, diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 17ce844..1f3831b 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "~/lib/auth"; +import { getDatabaseSetupErrorMessage } from "~/lib/db-errors"; import { env } from "~/env"; import { db } from "~/server/db"; import { accounts, users } from "~/server/db/schema"; @@ -161,6 +162,12 @@ export async function POST(request: NextRequest) { ); } catch (error) { console.error("Registration error:", error); + + const databaseSetupError = getDatabaseSetupErrorMessage(error); + if (databaseSetupError) { + return NextResponse.json({ error: databaseSetupError }, { status: 503 }); + } + return NextResponse.json( { error: "Internal server error" }, { status: 500 }, diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 7705514..f5f9063 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -3,6 +3,7 @@ import { z, type ZodType } from "zod"; import { createCaller } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; +import { getAppUrl } from "~/lib/app-url"; export const runtime = "nodejs"; @@ -856,7 +857,7 @@ const tools = { schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }), handler: async (input, caller) => { const result = await caller.invoices.generatePublicToken(input); - const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const base = getAppUrl(); return { ...result, webUrl: `${base}/i/${result.token}`, diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 3740304..88e3087 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -1,213 +1,5 @@ -"use client"; - -import { useState, Suspense } from "react"; -import { useRouter } from "next/navigation"; -import { Card, CardContent } from "~/components/ui/card"; -import { Input } from "~/components/ui/input"; -import { Button } from "~/components/ui/button"; -import { Label } from "~/components/ui/label"; -import { toast } from "sonner"; -import { Logo } from "~/components/branding/logo"; -import { LegalAgreementNotice } from "~/components/legal/legal-links"; -import { Mail, Lock, ArrowRight, User } from "lucide-react"; - -function formatAuthError(message: string | undefined, fallback: string): string { - if (!message || message === "Required") { - return fallback; - } - return message; -} - -function RegisterForm() { - const router = useRouter(); - const [loading, setLoading] = useState(false); - - async function handleRegister(e: React.FormEvent) { - e.preventDefault(); - - const formData = new FormData(e.currentTarget); - const trimmedFirstName = String(formData.get("firstName") ?? "").trim(); - const trimmedLastName = String(formData.get("lastName") ?? "").trim(); - const trimmedEmail = String(formData.get("email") ?? "").trim(); - const password = String(formData.get("password") ?? ""); - - if (!trimmedFirstName || !trimmedLastName || !trimmedEmail) { - toast.error("Please enter your first name, last name, and email."); - return; - } - - if (password.length < 8) { - toast.error("Password must be at least 8 characters."); - return; - } - - setLoading(true); - - try { - const res = await fetch("/api/auth/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - firstName: trimmedFirstName, - lastName: trimmedLastName, - email: trimmedEmail, - password, - }), - }); - - let data: { error?: string; signInRequired?: boolean } = {}; - try { - data = (await res.json()) as typeof data; - } catch { - toast.error("Registration failed. Please try again."); - return; - } - - if (!res.ok) { - toast.error( - formatAuthError(data.error, "Registration failed. Please check the form."), - ); - return; - } - - if (data.signInRequired) { - toast.success("Account created! Please sign in."); - router.push("/auth/signin"); - return; - } - - toast.success("Account created successfully!"); - router.push("/dashboard"); - router.refresh(); - } catch { - toast.error("Registration failed. Please try again."); - } finally { - setLoading(false); - } - } - - return ( -
-
-
-
-
- - - -
-
- -
-

Create your account

-

Get started today

-
-
- -
-
-
- -
- - -
-
- -
- -
- - -
-
-
- -
- -
- - -
-
- -
- -
- - -
-

At least 8 characters

-
- - -
- -

- Already have an account?{" "} - - Sign in - -

- - -
-
-
-
- ); -} +import { RegisterForm } from "./register-form"; export default function RegisterPage() { - return ( - Loading...}> - - - ); + return ; } diff --git a/src/app/auth/register/register-form.tsx b/src/app/auth/register/register-form.tsx new file mode 100644 index 0000000..6840cca --- /dev/null +++ b/src/app/auth/register/register-form.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ArrowRight, Lock, Mail, User } from "lucide-react"; +import { + AuthCard, + AuthCardHeader, + AuthPageShell, +} from "~/components/auth/auth-page-shell"; +import { LegalAgreementNotice } from "~/components/legal/legal-links"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { toast } from "sonner"; + +function formatAuthError(message: string | undefined, fallback: string): string { + if (!message || message === "Required") { + return fallback; + } + return message; +} + +export function RegisterForm() { + const router = useRouter(); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleRegister(e: React.FormEvent) { + e.preventDefault(); + + const trimmedFirstName = firstName.trim(); + const trimmedLastName = lastName.trim(); + const trimmedEmail = email.trim(); + + if (!trimmedFirstName || !trimmedLastName || !trimmedEmail) { + toast.error("Please enter your first name, last name, and email."); + return; + } + + if (password.length < 8) { + toast.error("Password must be at least 8 characters."); + return; + } + + setLoading(true); + + try { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + firstName: trimmedFirstName, + lastName: trimmedLastName, + email: trimmedEmail, + password, + }), + }); + + let data: { error?: string; signInRequired?: boolean } = {}; + try { + data = (await res.json()) as typeof data; + } catch { + toast.error("Registration failed. Please try again."); + return; + } + + if (!res.ok) { + toast.error( + formatAuthError(data.error, "Registration failed. Please check the form."), + ); + return; + } + + if (data.signInRequired) { + toast.success("Account created! Please sign in."); + router.push("/auth/signin"); + return; + } + + toast.success("Account created successfully!"); + router.push("/dashboard"); + router.refresh(); + } catch { + toast.error("Registration failed. Please try again."); + } finally { + setLoading(false); + } + } + + return ( + + + + +
+
+
+ +
+ + setFirstName(e.target.value)} + required + autoFocus + autoComplete="given-name" + className="h-11 pl-10" + placeholder="John" + /> +
+
+ +
+ +
+ + setLastName(e.target.value)} + required + autoComplete="family-name" + className="h-11 pl-10" + placeholder="Doe" + /> +
+
+
+ +
+ +
+ + setEmail(e.target.value)} + required + autoComplete="email" + className="h-11 pl-10" + placeholder="you@example.com" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + required + minLength={8} + autoComplete="new-password" + className="h-11 pl-10" + placeholder="••••••••" + /> +
+

At least 8 characters

+
+ + +
+ +

+ Already have an account?{" "} + + Sign in + +

+ + +
+
+ ); +} diff --git a/src/app/dashboard/_components/active-timer-widget.tsx b/src/app/dashboard/_components/active-timer-widget.tsx index 9378fb6..85a0b47 100644 --- a/src/app/dashboard/_components/active-timer-widget.tsx +++ b/src/app/dashboard/_components/active-timer-widget.tsx @@ -8,12 +8,32 @@ import { Button } from "~/components/ui/button"; import { Square, Clock } from "lucide-react"; import { toast } from "sonner"; import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/ui/tooltip"; +import { cn } from "~/lib/utils"; -export function ActiveTimerWidget() { +interface ActiveTimerWidgetProps { + collapsed?: boolean; + compact?: boolean; +} + +export function ActiveTimerWidget({ + collapsed = false, + compact = false, +}: ActiveTimerWidgetProps) { const utils = api.useUtils(); - const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(undefined, { - refetchInterval: 30_000, - }); + const { data: running, isLoading } = api.timeEntries.getRunning.useQuery( + undefined, + { + staleTime: 60_000, + refetchOnWindowFocus: false, + refetchInterval: 60_000, + }, + ); const [elapsed, setElapsed] = useState(0); const intervalRef = useRef | null>(null); @@ -56,9 +76,6 @@ export function ActiveTimerWidget() { } void utils.timeEntries.getRunning.invalidate(); - void utils.timeEntries.getAll.invalidate(); - void utils.invoices.getAll.invalidate(); - void utils.dashboard.getStats.invalidate(); }, onError: (e) => toast.error(e.message), }); @@ -69,64 +86,153 @@ export function ActiveTimerWidget() { ? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}` : null; + const description = + running.description || ( + No description + ); + + const renderStopButton = (className?: string) => ( + + ); + + if (compact) { + return ( +
+ + + + + + + {formatElapsedSeconds(elapsed)} + + + {renderStopButton()} +
+ ); + } + + if (collapsed) { + return ( +
+ + + + + + + + + + + + +

+ {description} + {running.client && ( + + {" "} + · {running.client.name} + + )} +

+

+ {formatElapsedSeconds(elapsed)} +

+ {invoiceLabel ? ( +

+ Billing to{" "} + + {invoiceLabel} + +

+ ) : ( +

No invoice selected

+ )} +
+ + {renderStopButton()} +
+
+
+
+
+ ); + } + return ( - - - - - - -
-

- {running.description || ( - No description - )} - {running.client && ( - · {running.client.name} - )} -

-

- {invoiceLabel ? ( - <> - Billing to{" "} - - {invoiceLabel} - - - ) : ( - <>No invoice selected — open time clock to assign - )} - {" · "} - - Time clock - -

+ +
+ + + + +
+

+ {description} + {running.client && ( + + {" "} + · {running.client.name} + + )} +

+

+ {invoiceLabel ? ( + <> + Billing to{" "} + + {invoiceLabel} + + + ) : ( + <>No invoice selected — open time clock to assign + )} + {" · "} + + Time clock + +

+
- - {formatElapsedSeconds(elapsed)} - - -
- - +
+ + {formatElapsedSeconds(elapsed)} + +
+ + {renderStopButton("w-full")} +
diff --git a/src/app/dashboard/_components/animated-stats-card.tsx b/src/app/dashboard/_components/animated-stats-card.tsx index 9c083c9..a60c9d4 100644 --- a/src/app/dashboard/_components/animated-stats-card.tsx +++ b/src/app/dashboard/_components/animated-stats-card.tsx @@ -8,7 +8,14 @@ import { Clock, Users, } from "lucide-react"; -import { Card, CardContent } from "~/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { cn } from "~/lib/utils"; type IconName = "DollarSign" | "Clock" | "Users" | "TrendingDown"; @@ -51,41 +58,36 @@ export function AnimatedStatsCard({ const isPositive = trend === "up"; const isNeutral = trend === "neutral"; - // For now, always use the formatted value prop to ensure correct display - // Animation can be added back once the basic display is working correctly - const displayValue = value; - - // Suppress unused parameter warnings for now void delay; void isCurrency; void numericValue; return ( - -
-
- -

{title}

-
-
- - {change} -
-
-
-

{displayValue}

-

{description}

+ + + + {title} + +
+ + {change}
+
+ +

+ {value} +

+ {description}
); diff --git a/src/app/dashboard/_components/invoice-status-chart.tsx b/src/app/dashboard/_components/invoice-status-chart.tsx index b957187..8e75edd 100644 --- a/src/app/dashboard/_components/invoice-status-chart.tsx +++ b/src/app/dashboard/_components/invoice-status-chart.tsx @@ -1,19 +1,18 @@ "use client"; -import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; +import { Cell, Pie, PieChart, Tooltip } from "recharts"; +import { ResponsiveChart } from "~/components/charts/responsive-chart"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; -import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; -import type { StoredInvoiceStatus } from "~/types/invoice"; -interface Invoice { - id: string; - totalAmount: number; +export interface StatusChartDatum { status: string; - dueDate: Date | string; + name: string; + count: number; + value: number; } interface InvoiceStatusChartProps { - invoices: Invoice[]; + data: StatusChartDatum[]; } const STATUS_COLORS = { @@ -47,52 +46,26 @@ function StatusTooltip({ return (

{data.name}

-

+

{data.count} invoice{data.count !== 1 ? "s" : ""}

-

{formatChartCurrency(data.value)}

+

+ {formatChartCurrency(data.value)} +

); } return null; } -export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) { - // Process invoice data to create status breakdown - const statusData = invoices.reduce( - (acc, invoice) => { - const effectiveStatus = getEffectiveInvoiceStatus( - invoice.status as StoredInvoiceStatus, - invoice.dueDate, - ); - - acc[effectiveStatus] ??= { - status: effectiveStatus, - count: 0, - value: 0, - }; - - acc[effectiveStatus].count += 1; - acc[effectiveStatus].value += invoice.totalAmount; - - return acc; - }, - {} as Record, - ); - - const chartData = Object.values(statusData).map((item) => ({ - ...item, - name: item.status.charAt(0).toUpperCase() + item.status.slice(1), - })); - - // Animation / motion preferences +export function InvoiceStatusChart({ data }: InvoiceStatusChartProps) { const { prefersReducedMotion, animationSpeedMultiplier } = useAnimationPreferences(); const pieAnimationDuration = Math.round( 600 / (animationSpeedMultiplier || 1), ); - if (chartData.length === 0) { + if (data.length === 0) { return (
@@ -109,11 +82,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) { return (
-
- - + + - {chartData.map((entry, index) => ( + {data.map((entry, index) => ( } /> - -
+ - {/* Legend */}
- {chartData.map((item) => ( + {data.map((item) => (
{item.name}
-

{item.count}

-

+

+ {item.count} +

+

{formatChartCurrency(item.value)}

diff --git a/src/app/dashboard/_components/monthly-metrics-chart.tsx b/src/app/dashboard/_components/monthly-metrics-chart.tsx index 437f890..d5025a5 100644 --- a/src/app/dashboard/_components/monthly-metrics-chart.tsx +++ b/src/app/dashboard/_components/monthly-metrics-chart.tsx @@ -3,25 +3,25 @@ import { Bar, BarChart, - ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; -import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; -import type { StoredInvoiceStatus } from "~/types/invoice"; +import { ResponsiveChart } from "~/components/charts/responsive-chart"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; -interface Invoice { - id: string; - totalAmount: number; - issueDate: Date | string; - status: string; - dueDate: Date | string; +export interface MonthlyMetricsChartDatum { + month: string; + monthLabel: string; + totalInvoices: number; + paidInvoices: number; + pendingInvoices: number; + overdueInvoices: number; + draftInvoices: number; } interface MonthlyMetricsChartProps { - invoices: Invoice[]; + data: MonthlyMetricsChartDatum[]; } function MonthlyMetricsTooltip({ @@ -31,28 +31,30 @@ function MonthlyMetricsTooltip({ }: { active?: boolean; payload?: Array<{ - payload: { - paidInvoices: number; - pendingInvoices: number; - overdueInvoices: number; - draftInvoices: number; - totalInvoices: number; - }; + payload: MonthlyMetricsChartDatum; }>; label?: string; }) { if (active && payload?.length) { - const data = payload[0]!.payload; + const chartDatum = payload[0]!.payload; return (

{label}

-

Paid: {data.paidInvoices}

-

Pending: {data.pendingInvoices}

-

Overdue: {data.overdueInvoices}

-

Draft: {data.draftInvoices}

-

- Total: {data.totalInvoices} +

+ Paid: {chartDatum.paidInvoices} +

+

+ Pending: {chartDatum.pendingInvoices} +

+

+ Overdue: {chartDatum.overdueInvoices} +

+

+ Draft: {chartDatum.draftInvoices} +

+

+ Total: {chartDatum.totalInvoices}

@@ -61,78 +63,14 @@ function MonthlyMetricsTooltip({ return null; } -export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) { - // Process invoice data to create monthly metrics - const monthlyData = invoices.reduce( - (acc, invoice) => { - const date = new Date(invoice.issueDate); - const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; - const effectiveStatus = getEffectiveInvoiceStatus( - invoice.status as StoredInvoiceStatus, - invoice.dueDate, - ); - - acc[monthKey] ??= { - month: monthKey, - totalInvoices: 0, - paidInvoices: 0, - pendingInvoices: 0, - overdueInvoices: 0, - draftInvoices: 0, - }; - - acc[monthKey].totalInvoices += 1; - - switch (effectiveStatus) { - case "paid": - acc[monthKey].paidInvoices += 1; - break; - case "sent": - acc[monthKey].pendingInvoices += 1; - break; - case "overdue": - acc[monthKey].overdueInvoices += 1; - break; - case "draft": - acc[monthKey].draftInvoices += 1; - break; - } - - return acc; - }, - {} as Record< - string, - { - month: string; - totalInvoices: number; - paidInvoices: number; - pendingInvoices: number; - overdueInvoices: number; - draftInvoices: number; - } - >, - ); - - // Convert to array and sort by month - const chartData = Object.values(monthlyData) - .sort((a, b) => a.month.localeCompare(b.month)) - .slice(-6) // Show last 6 months - .map((item) => ({ - ...item, - monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", { - month: "short", - year: "2-digit", - }), - })); - - // Animation / motion preferences +export function MonthlyMetricsChart({ data }: MonthlyMetricsChartProps) { const { prefersReducedMotion, animationSpeedMultiplier } = useAnimationPreferences(); const barAnimationDuration = Math.round( 500 / (animationSpeedMultiplier || 1), ); - if (chartData.length === 0) { + if (data.length === 0) { return (
@@ -149,9 +87,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) { return (
-
- - + + } /> - -
+ - {/* Legend */}

{label}

-

+

Revenue: {formatCurrency(data.revenue)}

@@ -84,9 +87,8 @@ export function RevenueChart({ data }: RevenueChartProps) { } return ( -

- - + + } /> @@ -127,7 +133,6 @@ export function RevenueChart({ data }: RevenueChartProps) { animationEasing="ease-out" /> - -
+ ); } diff --git a/src/app/dashboard/administration/page.tsx b/src/app/dashboard/administration/page.tsx index 0751c6a..e8a96ad 100644 --- a/src/app/dashboard/administration/page.tsx +++ b/src/app/dashboard/administration/page.tsx @@ -1,16 +1,34 @@ +import { eq } from "drizzle-orm"; +import { redirect } from "next/navigation"; import { Suspense } from "react"; import { DataTableSkeleton } from "~/components/data/data-table"; -import { PageHeader } from "~/components/layout/page-header"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { DashboardPage } from "~/components/layout/dashboard-page"; +import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server"; +import { db } from "~/server/db"; +import { users } from "~/server/db/schema"; import { HydrateClient } from "~/trpc/server"; import { AdministrationContent } from "./_components/administration-content"; export default async function AdministrationPage() { + const session = await getOptionalServerSessionFromHeaders(); + + if (session?.user) { + const user = await db.query.users.findFirst({ + where: eq(users.id, session.user.id), + columns: { role: true }, + }); + + if (user?.role !== "admin") { + redirect("/dashboard"); + } + } + return ( -
- + @@ -18,6 +36,6 @@ export default async function AdministrationPage() { -
+ ); } diff --git a/src/app/dashboard/businesses/[id]/page.tsx b/src/app/dashboard/businesses/[id]/page.tsx index 7e9cd1a..a926074 100644 --- a/src/app/dashboard/businesses/[id]/page.tsx +++ b/src/app/dashboard/businesses/[id]/page.tsx @@ -3,7 +3,13 @@ import { api } from "~/trpc/server"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; -import { PageHeader } from "~/components/layout/page-header"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { + DashboardPage, + dashboardGapClass, + dashboardGridClass, +} from "~/components/layout/dashboard-page"; +import { cn } from "~/lib/utils"; import { Separator } from "~/components/ui/separator"; import Link from "next/link"; import { @@ -43,11 +49,10 @@ export default async function BusinessDetailPage({ }; return ( -
- + - + -
+
{/* Business Information Card */}
@@ -265,7 +270,7 @@ export default async function BusinessDetailPage({
{/* Settings & Actions Card */} -
+
@@ -323,6 +328,6 @@ export default async function BusinessDetailPage({
-
+ ); } diff --git a/src/app/dashboard/clients/[id]/page.tsx b/src/app/dashboard/clients/[id]/page.tsx index 3353b16..456c1e4 100644 --- a/src/app/dashboard/clients/[id]/page.tsx +++ b/src/app/dashboard/clients/[id]/page.tsx @@ -3,7 +3,13 @@ import { api } from "~/trpc/server"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; -import { PageHeader } from "~/components/layout/page-header"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { + DashboardPage, + dashboardGapClass, + dashboardGridClass, +} from "~/components/layout/dashboard-page"; +import { cn } from "~/lib/utils"; import Link from "next/link"; import { Edit, @@ -57,11 +63,10 @@ export default async function ClientDetailPage({ client.invoices?.filter((invoice) => invoice.status === "sent").length || 0; return ( -
- + - + -
+
{/* Client Information Card */}
@@ -173,7 +178,7 @@ export default async function ClientDetailPage({
{/* Stats Card */} -
+
@@ -275,6 +280,6 @@ export default async function ClientDetailPage({ )}
-
+ ); } diff --git a/src/app/dashboard/entities/_components/entities-view.tsx b/src/app/dashboard/entities/_components/entities-view.tsx index 04cef7d..7c9d468 100644 --- a/src/app/dashboard/entities/_components/entities-view.tsx +++ b/src/app/dashboard/entities/_components/entities-view.tsx @@ -3,15 +3,32 @@ import { Plus } from "lucide-react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { PageHeader } from "~/components/layout/page-header"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { + PageTabs, + PageTabsContent, + PageTabsList, + PageTabsTrigger, +} from "~/components/layout/page-tabs"; import { Button } from "~/components/ui/button"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; -import { ClientsTable } from "../../clients/_components/clients-table"; -import { BusinessesTable } from "../../businesses/_components/businesses-table"; +import { ClientsDataTable } from "../../clients/_components/clients-data-table"; +import { BusinessesDataTable } from "../../businesses/_components/businesses-data-table"; +import type { RouterOutputs } from "~/trpc/react"; type EntityTab = "clients" | "businesses"; -export function EntitiesView({ initialTab }: { initialTab: EntityTab }) { +type Client = RouterOutputs["clients"]["getAll"][number]; +type Business = RouterOutputs["businesses"]["getAll"][number]; + +export function EntitiesView({ + initialTab, + clients, + businesses, +}: { + initialTab: EntityTab; + clients: Client[]; + businesses: Business[]; +}) { const router = useRouter(); const searchParams = useSearchParams(); const tab: EntityTab = @@ -27,11 +44,10 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) { const addLabel = tab === "clients" ? "Add client" : "Add business"; return ( -
- + - + - - - Clients - Businesses - + + + Clients + Businesses + - - - + + {tab === "clients" ? : null} + - - - - -
+ + {tab === "businesses" ? ( + + ) : null} + + + ); } diff --git a/src/app/dashboard/entities/page.tsx b/src/app/dashboard/entities/page.tsx index abfde3c..b3ab799 100644 --- a/src/app/dashboard/entities/page.tsx +++ b/src/app/dashboard/entities/page.tsx @@ -1,6 +1,5 @@ -import { Suspense } from "react"; -import { DataTableSkeleton } from "~/components/data/data-table"; -import { api, HydrateClient } from "~/trpc/server"; +import { api } from "~/trpc/server"; +import { DashboardPage } from "~/components/layout/dashboard-page"; import { EntitiesView } from "./_components/entities-view"; export default async function EntitiesPage({ @@ -11,16 +10,18 @@ export default async function EntitiesPage({ const params = await searchParams; const initialTab = params.tab === "businesses" ? "businesses" : "clients"; - void api.clients.getAll.prefetch(); - void api.businesses.getAll.prefetch(); + const [clients, businesses] = await Promise.all([ + api.clients.getAll(), + api.businesses.getAll(), + ]); return ( -
- - }> - - - -
+ + + ); } diff --git a/src/app/dashboard/expenses/page.tsx b/src/app/dashboard/expenses/page.tsx index 661aae6..2cd07a8 100644 --- a/src/app/dashboard/expenses/page.tsx +++ b/src/app/dashboard/expenses/page.tsx @@ -2,7 +2,8 @@ import { useState } from "react"; import { api } from "~/trpc/react"; -import { PageHeader } from "~/components/layout/page-header"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Badge } from "~/components/ui/badge"; @@ -145,11 +146,10 @@ export default function ExpensesPage() { .reduce((s, e) => s + e.amount, 0); return ( -
- + - + - {/* Summary cards */} -
+

@@ -476,6 +475,6 @@ export default function ExpensesPage() { -

+ ); } diff --git a/src/app/dashboard/invoices/[id]/_components/invoice-details-skeleton.tsx b/src/app/dashboard/invoices/[id]/_components/invoice-details-skeleton.tsx index 1511111..8069207 100644 --- a/src/app/dashboard/invoices/[id]/_components/invoice-details-skeleton.tsx +++ b/src/app/dashboard/invoices/[id]/_components/invoice-details-skeleton.tsx @@ -1,25 +1,27 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Separator } from "~/components/ui/separator"; import { Skeleton } from "~/components/ui/skeleton"; -import { PageHeader } from "~/components/layout/page-header"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { + DashboardPage, + dashboardGapClass, + dashboardGridClass, +} from "~/components/layout/dashboard-page"; +import { cn } from "~/lib/utils"; export function InvoiceDetailsSkeleton() { return ( -
- {/* Header */} - + - + - {/* Content */} -
- {/* Left Column */} -
+
+
{/* Invoice Header Skeleton */} @@ -155,7 +157,7 @@ export function InvoiceDetailsSkeleton() {
{/* Right Column - Actions */} -
+
@@ -172,6 +174,6 @@ export function InvoiceDetailsSkeleton() {
-
+ ); } diff --git a/src/app/dashboard/invoices/[id]/_components/pdf-download-button.tsx b/src/app/dashboard/invoices/[id]/_components/pdf-download-button.tsx index 9beb808..043038e 100644 --- a/src/app/dashboard/invoices/[id]/_components/pdf-download-button.tsx +++ b/src/app/dashboard/invoices/[id]/_components/pdf-download-button.tsx @@ -25,7 +25,7 @@ export function PDFDownloadButton({ { id: invoiceId }, { enabled: false }, ); - const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, { + const { data: pdfSettings } = api.settings.getPdfSettings.useQuery(undefined, { staleTime: 60_000, }); @@ -59,11 +59,11 @@ export function PDFDownloadButton({ }; await generateInvoicePDF(pdfData, { - pdfTemplate: platformTheme?.pdfTemplate, - pdfAccentColor: platformTheme?.pdfAccentColor, - pdfFooterText: platformTheme?.pdfFooterText, - pdfShowLogo: platformTheme?.pdfShowLogo, - pdfShowPageNumbers: platformTheme?.pdfShowPageNumbers, + pdfTemplate: pdfSettings?.pdfTemplate, + pdfAccentColor: pdfSettings?.pdfAccentColor, + pdfFooterText: pdfSettings?.pdfFooterText, + pdfShowLogo: pdfSettings?.pdfShowLogo, + pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers, }); toast.success("PDF downloaded successfully"); } catch (error) { diff --git a/src/app/dashboard/invoices/[id]/page.tsx b/src/app/dashboard/invoices/[id]/page.tsx index d7134be..b8259b0 100644 --- a/src/app/dashboard/invoices/[id]/page.tsx +++ b/src/app/dashboard/invoices/[id]/page.tsx @@ -24,7 +24,13 @@ import { notFound, useParams, useRouter } from "next/navigation"; import { useState, useEffect } from "react"; import { toast } from "sonner"; import { StatusBadge } from "~/components/data/status-badge"; -import { PageHeader } from "~/components/layout/page-header"; +import { + DashboardPage, + dashboardGapClass, + dashboardGridClass, +} from "~/components/layout/dashboard-page"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; @@ -224,11 +230,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { }; return ( -
- + - + -
+
{/* Left Column */} -
+
{/* Invoice Header */} @@ -531,7 +536,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
{/* Right Column - Actions */} -
+
{storedStatus === "draft" && ( )} @@ -833,7 +838,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { -
+ ); } diff --git a/src/app/dashboard/invoices/[id]/send/page.tsx b/src/app/dashboard/invoices/[id]/send/page.tsx index e424671..9231d99 100644 --- a/src/app/dashboard/invoices/[id]/send/page.tsx +++ b/src/app/dashboard/invoices/[id]/send/page.tsx @@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from "react"; import { useParams, useRouter } from "next/navigation"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Badge } from "~/components/ui/badge"; import { Separator } from "~/components/ui/separator"; import { Alert, AlertDescription } from "~/components/ui/alert"; @@ -17,7 +16,20 @@ import { DialogHeader, DialogTitle, } from "~/components/ui/dialog"; -import { PageHeader } from "~/components/layout/page-header"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { + DashboardPage, + dashboardGapClass, + dashboardGridClass, +} from "~/components/layout/dashboard-page"; +import { + PageTabs, + PageTabsContent, + PageTabsList, + PageTabsTrigger, +} from "~/components/layout/page-tabs"; +import { cn } from "~/lib/utils"; +import { NOREPLY_EMAIL } from "~/lib/app-email"; import { FloatingActionBar } from "~/components/layout/floating-action-bar"; import { EmailComposer } from "~/components/forms/email-composer"; import { EmailPreview } from "~/components/forms/email-preview"; @@ -36,21 +48,20 @@ import { function SendEmailPageSkeleton() { return ( -
- + -
-
+
+
-
+
-
+ ); } @@ -280,7 +291,7 @@ export default function SendEmailPage() { } }; - const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com"; + const fromEmail = invoice?.business?.email ?? NOREPLY_EMAIL; const toEmail = invoice?.client?.email ?? ""; const canSend = @@ -292,18 +303,18 @@ export default function SendEmailPage() { if (!invoice) { return ( -
+ Invoice not found. -
+ ); } return ( -
- + - + {/* Warning for missing email */} {(!toEmail || toEmail.trim() === "") && ( @@ -336,23 +346,22 @@ export default function SendEmailPage() { )} {/* Main Content */} -
+
- - - + + + Compose - - + + Preview - - + + -
- - + + @@ -387,10 +396,10 @@ export default function SendEmailPage() { )} - + - - + + @@ -413,13 +422,12 @@ export default function SendEmailPage() {
- -
- + +
{/* Sidebar */} -
+
{/* Invoice Summary */} @@ -644,6 +652,6 @@ export default function SendEmailPage() { -
+ ); } diff --git a/src/app/dashboard/invoices/import/page.tsx b/src/app/dashboard/invoices/import/page.tsx index 6efcf77..dabb524 100644 --- a/src/app/dashboard/invoices/import/page.tsx +++ b/src/app/dashboard/invoices/import/page.tsx @@ -10,7 +10,9 @@ import { } from "lucide-react"; import Link from "next/link"; import { CSVImportPage } from "~/components/csv-import-page"; -import { PageHeader } from "~/components/layout/page-header"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { DashboardPage, dashboardGridClass } from "~/components/layout/dashboard-page"; +import { cn } from "~/lib/utils"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; @@ -19,7 +21,7 @@ import { HydrateClient } from "~/trpc/server"; // File Upload Instructions Component function FormatInstructions() { return ( -
+
{/* Required Format */} @@ -203,11 +205,10 @@ function FileFormatHelp() { export default async function ImportPage() { return ( -
- + - + {/* Main CSV Import Component */} @@ -230,6 +231,6 @@ export default async function ImportPage() { {/* Important Notes */} -
+ ); } diff --git a/src/app/dashboard/invoices/page.tsx b/src/app/dashboard/invoices/page.tsx index c9fed2d..fb5cff3 100644 --- a/src/app/dashboard/invoices/page.tsx +++ b/src/app/dashboard/invoices/page.tsx @@ -2,7 +2,8 @@ import Link from "next/link"; import { Suspense } from "react"; import { api, HydrateClient } from "~/trpc/server"; import { Button } from "~/components/ui/button"; -import { PageHeader } from "~/components/layout/page-header"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { DashboardPage } from "~/components/layout/dashboard-page"; import { FileText, Plus, Upload } from "lucide-react"; import { InvoicesDataTable } from "./_components/invoices-data-table"; import { DataTableSkeleton } from "~/components/data/data-table"; @@ -16,11 +17,10 @@ async function InvoicesTable() { export default async function InvoicesPage() { return ( -
- + - + }> -
+ ); } diff --git a/src/app/dashboard/invoices/recurring/page.tsx b/src/app/dashboard/invoices/recurring/page.tsx index 23f6f13..a0bea12 100644 --- a/src/app/dashboard/invoices/recurring/page.tsx +++ b/src/app/dashboard/invoices/recurring/page.tsx @@ -13,7 +13,8 @@ import { import { useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { PageHeader } from "~/components/layout/page-header"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { DashboardPage } from "~/components/layout/dashboard-page"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { Card, CardContent } from "~/components/ui/card"; @@ -358,17 +359,16 @@ export default function RecurringInvoicesPage() { const isSubmitting = create.isPending || update.isPending; return ( -
- + - + {isLoading ? (
@@ -529,6 +529,6 @@ export default function RecurringInvoicesPage() { -
+ ); } diff --git a/src/app/dashboard/invoices/templates/page.tsx b/src/app/dashboard/invoices/templates/page.tsx index 3b0cdbc..9fa8ee1 100644 --- a/src/app/dashboard/invoices/templates/page.tsx +++ b/src/app/dashboard/invoices/templates/page.tsx @@ -2,7 +2,14 @@ import { useState } from "react"; import { api, type RouterOutputs } from "~/trpc/react"; -import { PageHeader } from "~/components/layout/page-header"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { DashboardPage } from "~/components/layout/dashboard-page"; +import { + PageTabs, + PageTabsContent, + PageTabsList, + PageTabsTrigger, +} from "~/components/layout/page-tabs"; import { Button } from "~/components/ui/button"; import { Card, CardContent } from "~/components/ui/card"; import { Badge } from "~/components/ui/badge"; @@ -18,7 +25,7 @@ import { DialogHeader, DialogTitle, } from "~/components/ui/dialog"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs"; +import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { toast } from "sonner"; import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react"; @@ -187,25 +194,24 @@ export default function TemplatesPage() { const termsTemplates = templates.filter((t) => t.type === "terms"); return ( -
- + - setTab(v as "notes" | "terms")}> - - + setTab(v as "notes" | "terms")}> + + Notes ( {notesTemplates.length}) - - + + Terms ( {termsTemplates.length}) - - - + + + - - + + - - + + {/* Create/Edit dialog */} @@ -320,6 +326,6 @@ export default function TemplatesPage() { -
+ ); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index c3257f5..0ba6097 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,7 +1,11 @@ +import { eq } from "drizzle-orm"; import { redirect } from "next/navigation"; import { AppProviders } from "~/components/providers/app-providers"; import { DashboardShell } from "~/components/layout/dashboard-shell"; +import { DashboardUserProvider } from "~/components/layout/dashboard-user-context"; import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server"; +import { db } from "~/server/db"; +import { users } from "~/server/db/schema"; export const dynamic = "force-dynamic"; @@ -16,9 +20,22 @@ export default async function DashboardLayout({ redirect("/auth/signin?callbackUrl=/dashboard"); } + const user = await db.query.users.findFirst({ + where: eq(users.id, session.user.id), + columns: { + role: true, + onboardingCompletedAt: true, + }, + }); + + const isAdmin = user?.role === "admin"; + const needsOnboarding = user?.onboardingCompletedAt == null; + return ( - {children} + + {children} + ); } diff --git a/src/app/dashboard/onboarding/_components/onboarding-wizard.tsx b/src/app/dashboard/onboarding/_components/onboarding-wizard.tsx new file mode 100644 index 0000000..e414fc9 --- /dev/null +++ b/src/app/dashboard/onboarding/_components/onboarding-wizard.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + ArrowRight, + Building2, + CheckCircle2, + Sparkles, + Users, +} from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { api } from "~/trpc/react"; + +type Step = "welcome" | "business" | "client" | "done"; + +export function OnboardingWizard() { + const router = useRouter(); + const utils = api.useUtils(); + const { data: status, isLoading } = api.settings.getOnboardingStatus.useQuery(); + + const [step, setStep] = useState("welcome"); + const [businessName, setBusinessName] = useState(""); + const [clientName, setClientName] = useState(""); + + const createBusiness = api.businesses.create.useMutation({ + onSuccess: async () => { + toast.success("Business added"); + await utils.settings.getOnboardingStatus.invalidate(); + setStep("client"); + }, + onError: (error) => toast.error(error.message), + }); + + const createClient = api.clients.create.useMutation({ + onSuccess: async () => { + toast.success("Client added"); + await utils.settings.getOnboardingStatus.invalidate(); + setStep("done"); + }, + onError: (error) => toast.error(error.message), + }); + + const completeOnboarding = api.settings.completeOnboarding.useMutation({ + onSuccess: () => { + router.push("/dashboard"); + router.refresh(); + }, + onError: (error) => toast.error(error.message), + }); + + useEffect(() => { + if (status?.completed) { + router.replace("/dashboard"); + } + }, [status?.completed, router]); + + useEffect(() => { + if (!isLoading && status && !status.completed && step === "welcome") { + if (status.businessCount > 0 && status.clientCount > 0) { + setStep("done"); + } else if (status.businessCount > 0) { + setStep("client"); + } + } + }, [isLoading, status, step]); + + function handleSkip() { + completeOnboarding.mutate(); + } + + function handleBusinessSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!businessName.trim()) { + toast.error("Business name is required"); + return; + } + createBusiness.mutate({ + name: businessName.trim(), + isDefault: true, + }); + } + + function handleClientSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!clientName.trim()) { + toast.error("Client name is required"); + return; + } + createClient.mutate({ name: clientName.trim() }); + } + + function handleFinish() { + completeOnboarding.mutate(); + } + + function handleCreateInvoice() { + completeOnboarding.mutate(undefined, { + onSuccess: () => { + router.push("/dashboard/invoices/new"); + router.refresh(); + }, + }); + } + + if (isLoading || status?.completed) { + return ( +
+

Loading…

+
+ ); + } + + return ( +
+
+ +

+ {step === "welcome" && "Step 1 of 3"} + {step === "business" && "Step 2 of 3"} + {step === "client" && "Step 3 of 3"} + {step === "done" && "All set"} +

+
+ + {step === "welcome" && ( + + + Welcome to BeenVoice + + Let's set up the basics so you can send your first invoice. + This only takes a minute. + + + +
+
+ +

Add the business you send invoices from

+
+
+ +

Add your first client to bill

+
+
+
+ + +
+
+
+ )} + + {step === "business" && ( + + + Your business + + This appears on invoices as the sender — name, logo, and contact + details. + + + +
+
+ + setBusinessName(e.target.value)} + placeholder="Acme Studio LLC" + autoFocus + /> +
+
+ + +
+
+
+
+ )} + + {step === "client" && ( + + + Your first client + + Who are you billing? You can add more details later. + + + +
+
+ + setClientName(e.target.value)} + placeholder="Acme Corp" + autoFocus + /> +
+
+ + +
+
+
+
+ )} + + {step === "done" && ( + + + + + You're ready to go + + + Your workspace is set up. Create an invoice or explore the + dashboard. + + + + + + + + )} + + {step !== "welcome" && step !== "done" && ( + + )} +
+ ); +} diff --git a/src/app/dashboard/onboarding/page.tsx b/src/app/dashboard/onboarding/page.tsx new file mode 100644 index 0000000..b664f27 --- /dev/null +++ b/src/app/dashboard/onboarding/page.tsx @@ -0,0 +1,10 @@ +import { DashboardPage } from "~/components/layout/dashboard-page"; +import { OnboardingWizard } from "./_components/onboarding-wizard"; + +export default function OnboardingPage() { + return ( + + + + ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 1f4ad7b..6989764 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -10,24 +10,34 @@ import { Users, } from "lucide-react"; import Link from "next/link"; -import { Suspense } from "react"; +import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card"; +import { + InvoiceStatusChart, + MonthlyMetricsChart, + RevenueChart, +} from "~/app/dashboard/_components/charts-client"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { + DashboardCardTitle, + DashboardGrid, + DashboardPage as DashboardPageLayout, + dashboardGridClass, +} from "~/components/layout/dashboard-page"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; -import { Skeleton } from "~/components/ui/skeleton"; -import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server"; -import { HydrateClient, api } from "~/trpc/server"; -import type { StoredInvoiceStatus } from "~/types/invoice"; -import { RevenueChart, InvoiceStatusChart, MonthlyMetricsChart } from "~/app/dashboard/_components/charts-client"; -import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card"; +import { cn } from "~/lib/utils"; +import { api } from "~/trpc/server"; import type { DashboardStats, RecentInvoice } from "./types"; -// Hero section with clean mono design - -// Enhanced stats cards with better visuals function DashboardStats({ stats }: { stats: DashboardStats }) { - // TODO: Import RouterOutput type const formatTrend = (value: number, isCount = false) => { if (isCount) { return value > 0 ? `+${value}` : value.toString(); @@ -44,42 +54,42 @@ function DashboardStats({ stats }: { stats: DashboardStats }) { change: formatTrend(stats.revenueChange), trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const), iconName: "DollarSign" as const, - description: "Total collected revenue", + description: "Collected to date", }, { - title: "Pending Amount", + title: "Pending", value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`, numericValue: stats.pendingAmount, isCurrency: true, - change: "0%", // TODO: Calculate pending change if needed + change: "0%", trend: "neutral" as const, iconName: "Clock" as const, - description: "Invoices awaiting payment", + description: "Awaiting payment", }, { - title: "Active Clients", + title: "Clients", value: stats.totalClients.toString(), numericValue: stats.totalClients, isCurrency: false, - change: "0", // TODO: Calculate client change if needed + change: "0", trend: "neutral" as const, iconName: "Users" as const, - description: "Total registered clients", + description: "Active clients", }, { - title: "Overdue Invoices", + title: "Overdue", value: stats.overdueCount.toString(), numericValue: stats.overdueCount, isCurrency: false, - change: "0", // TODO: Calculate overdue change if needed + change: "0", trend: "neutral" as const, iconName: "TrendingDown" as const, - description: "Invoices past due date", + description: "Past due date", }, ]; return ( -
+
{statCards.map((stat, index) => ( - {/* Revenue Trend Chart */} + - - - Revenue Over Time + + + Revenue over time + @@ -120,55 +124,54 @@ async function ChartsSection({ stats }: { stats: DashboardStats }) { - {/* Invoice Status Breakdown */} - - - Invoice Status + + + Invoice status + - + - {/* Monthly Metrics */} - - - Monthly Metrics + + + Monthly metrics + - + -
+ ); } -// Enhanced Quick Actions function QuickActions() { const actions = [ { - title: "Create Invoice", + title: "Create invoice", description: "Start a new invoice for a client", href: "/dashboard/invoices/new", icon: FileText, featured: true, }, { - title: "Add Client", - description: "Register a new client", + title: "Add client", + description: "Register someone you bill", href: "/dashboard/clients/new", icon: Users, featured: false, }, { - title: "View All Invoices", - description: "Manage your invoice pipeline", + title: "View invoices", + description: "Browse your full pipeline", href: "/dashboard/invoices", icon: BarChart3, featured: false, @@ -178,27 +181,36 @@ function QuickActions() { return ( - - - Quick Actions + + Quick actions - + {actions.map((action) => { const Icon = action.icon; return ( - +
+ +
-

{action.title}

+

{action.title}

{action.description}

@@ -211,204 +223,168 @@ function QuickActions() { ); } -// Current work section with enhanced design -async function CurrentWork() { - const invoices = await api.invoices.getAll(); - const draftInvoices = invoices.filter( - (invoice) => - getEffectiveInvoiceStatus( - invoice.status as StoredInvoiceStatus, - invoice.dueDate, - ) === "draft", - ); - const currentInvoice = draftInvoices[0]; - - if (!currentInvoice) { +function CurrentWork({ + currentDraft, +}: { + currentDraft: DashboardStats["currentDraft"]; +}) { + if (!currentDraft) { return ( - - - Current Work + + + Current work + - -
- -

No active drafts

-

- Create a new invoice to get started -

- + +
+
+

No draft in progress

+ + Start an invoice when you're ready to bill your next piece of + work. + +
); } - const totalHours = - currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0; + const totalHours = currentDraft.totalHours; return ( - - - - Current Work + + + Current work - In Progress + Draft - -
-
-
-

- #{currentInvoice.invoiceNumber} -

- - ${currentInvoice.totalAmount.toFixed(2)} - -
-
- {currentInvoice.client?.name} - - {totalHours.toFixed(1)} hours logged - + +
+
+
+

#{currentDraft.invoiceNumber}

+

+ {currentDraft.client?.name} +

+

+ ${currentDraft.totalAmount.toFixed(2)} +

+

+ {totalHours.toFixed(1)} hours logged +

+
-
- - -
+
+ +
); } -// Enhanced recent activity -async function RecentActivity({ +function RecentActivity({ recentInvoices, }: { recentInvoices: RecentInvoice[]; }) { - // Use passed recentInvoices instead of fetching all - - const getStatusStyle = (status: string) => { + const getStatusVariant = (status: string) => { switch (status) { case "paid": - return { - backgroundColor: "oklch(var(--chart-2) / 0.1)", - borderColor: "oklch(var(--chart-2) / 0.3)", - color: "oklch(var(--chart-2))", - }; + return "default" as const; case "sent": - return { - backgroundColor: "oklch(var(--chart-1) / 0.1)", - borderColor: "oklch(var(--chart-1) / 0.3)", - color: "oklch(var(--chart-1))", - }; + return "secondary" as const; case "overdue": - return { - backgroundColor: "oklch(var(--chart-3) / 0.1)", - borderColor: "oklch(var(--chart-3) / 0.3)", - color: "oklch(var(--chart-3))", - }; + return "destructive" as const; default: - return { - backgroundColor: "hsl(var(--muted))", - borderColor: "hsl(var(--border))", - color: "hsl(var(--muted-foreground))", - }; + return "outline" as const; } }; return ( - - - - - Recent Activity + + + + + Recent activity + {recentInvoices.length === 0 ? ( -
- -

No invoices yet

-

- Create your first invoice to get started -

-
) : ( -
- {recentInvoices.map((invoice, _index) => ( +
+ {recentInvoices.map((invoice) => ( -
-
-
- -
-
-
-
-

- #{invoice.invoiceNumber} -

-

- {invoice.client?.name} -

-
-
- - {invoice.status} - - - ${invoice.totalAmount.toFixed(2)} - -
-
-

- {new Date(invoice.issueDate).toLocaleDateString()} -

-
+
+ +
+
+
+

+ #{invoice.invoiceNumber} +

+ + ${invoice.totalAmount.toFixed(2)} + +
+
+

+ {invoice.client?.name} +

+ + {invoice.status} +
@@ -420,121 +396,27 @@ async function RecentActivity({ ); } -// Loading skeletons -function StatsSkeleton() { - return ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - -
- - -
- - -
-
- ))} -
- ); -} - -function ChartsSkeleton() { - return ( -
- - - - - - - - - - - - - - - - - - - - - - - - -
- ); -} - -function CardSkeleton() { - return ( - - - - - -
- - - -
-
-
- ); -} - -import { DashboardPageHeader } from "~/components/layout/page-header"; - -// ... imports - export default async function DashboardPage() { const session = await getOptionalServerSessionFromHeaders(); const firstName = session?.user?.name?.split(" ")[0] ?? "User"; - - // Fetch stats centrally const stats = await api.dashboard.getStats(); - void api.timeEntries.getRunning.prefetch(); return ( -
+ - - }> - - - - - - }> - - - - -
-
- - }> - - - + + + +
+
- - - }> - - - -
-
+ + +
); } diff --git a/src/app/dashboard/reports/page.tsx b/src/app/dashboard/reports/page.tsx index b4ff07b..ca5f783 100644 --- a/src/app/dashboard/reports/page.tsx +++ b/src/app/dashboard/reports/page.tsx @@ -2,7 +2,8 @@ import { useMemo, useState } from "react"; import { api } from "~/trpc/react"; -import { PageHeader } from "~/components/layout/page-header"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { StatusBadge } from "~/components/data/status-badge"; import { Button } from "~/components/ui/button"; @@ -14,7 +15,12 @@ import { SelectValue, } from "~/components/ui/select"; import { Separator } from "~/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; +import { + PageTabs, + PageTabsContent, + PageTabsList, + PageTabsTrigger, +} from "~/components/layout/page-tabs"; import { formatCurrency } from "~/lib/currency"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import type { StoredInvoiceStatus } from "~/types/invoice"; @@ -308,42 +314,40 @@ export default function ReportsPage() { if (isLoading) { return ( -
- + -
+
{Array.from({ length: 4 }).map((_, i) => (
))}
-
+ ); } return ( -
- + - - - - Overview - - - Tax Summary - - + + + + Overview + + + Tax Summary + + {/* ── OVERVIEW TAB ── */} - -
+ +
@@ -630,10 +634,10 @@ export default function ReportsPage() { )} - + {/* ── TAX SUMMARY TAB ── */} - +
Tax Year @@ -840,8 +844,8 @@ export default function ReportsPage() {
- - -
+
+ + ); } diff --git a/src/app/dashboard/settings/_components/settings-content.tsx b/src/app/dashboard/settings/_components/settings-content.tsx index a6eddb4..6708239 100644 --- a/src/app/dashboard/settings/_components/settings-content.tsx +++ b/src/app/dashboard/settings/_components/settings-content.tsx @@ -12,16 +12,13 @@ import { FileUp, Info, Key, + Monitor, Palette, Shield, Upload, User, Users, Link as LinkIcon, - Monitor, - PanelLeft, - Paintbrush, - Type, } from "lucide-react"; import dynamic from "next/dynamic"; import { authClient } from "~/lib/auth-client"; @@ -70,11 +67,17 @@ import { Label } from "~/components/ui/label"; import { Textarea } from "~/components/ui/textarea"; import { api } from "~/trpc/react"; import { env } from "~/env"; -import { Badge } from "~/components/ui/badge"; import { Switch } from "~/components/ui/switch"; import { Slider } from "~/components/ui/slider"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; +import { + PageTabs, + PageTabsContent, + PageTabsList, + PageTabsTrigger, + pageTabsGridClass, +} from "~/components/layout/page-tabs"; +import { cn } from "~/lib/utils"; import { Select, SelectContent, @@ -83,19 +86,8 @@ import { SelectValue, } from "~/components/ui/select"; import { useAppearance } from "~/components/providers/appearance-provider"; -import { - bodyFontPreferences, - brand, - colorModes, - colorThemes, - type ColorTheme, - headingFontPreferences, - interfaceThemes, - radiusPreferences, - sidebarStyles, - themePresets, - type InterfaceTheme, -} from "~/lib/branding"; +import { brand, colorModes } from "~/lib/branding"; +import type { PdfTemplate } from "~/lib/appearance"; import { ApiAccessSettings } from "./api-access-settings"; const PdfPreviewFrame = dynamic( @@ -110,72 +102,6 @@ const PdfPreviewFrame = dynamic( }, ); -function hslChannelsToHex(channels?: string) { - const [hue, saturation, lightness] = - channels?.match(/[\d.]+/g)?.map(Number) ?? []; - - if ( - hue === undefined || - saturation === undefined || - lightness === undefined - ) { - return "#16a34a"; - } - - const s = saturation / 100; - const l = lightness / 100; - const c = (1 - Math.abs(2 * l - 1)) * s; - const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); - const m = l - c / 2; - const [r, g, b] = - hue < 60 - ? [c, x, 0] - : hue < 120 - ? [x, c, 0] - : hue < 180 - ? [0, c, x] - : hue < 240 - ? [0, x, c] - : hue < 300 - ? [x, 0, c] - : [c, 0, x]; - - return `#${[r, g, b] - .map((channel) => - Math.round((channel + m) * 255) - .toString(16) - .padStart(2, "0"), - ) - .join("")}`; -} - -function hexToHslChannels(hex: string) { - const normalized = hex.replace("#", ""); - const red = parseInt(normalized.slice(0, 2), 16) / 255; - const green = parseInt(normalized.slice(2, 4), 16) / 255; - const blue = parseInt(normalized.slice(4, 6), 16) / 255; - const max = Math.max(red, green, blue); - const min = Math.min(red, green, blue); - const lightness = (max + min) / 2; - const delta = max - min; - - if (delta === 0) { - return `0 0% ${Number((lightness * 100).toFixed(1))}%`; - } - - const saturation = delta / (1 - Math.abs(2 * lightness - 1)); - const hue = - max === red - ? 60 * (((green - blue) / delta) % 6) - : max === green - ? 60 * ((blue - red) / delta + 2) - : 60 * ((red - green) / delta + 4); - - return `${Number(((hue + 360) % 360).toFixed(1))} ${Number( - (saturation * 100).toFixed(1), - )}% ${Number((lightness * 100).toFixed(1))}%`; -} - function isFullHexColor(value: string) { return /^#[0-9A-Fa-f]{6}$/.test(value); } @@ -197,43 +123,28 @@ export function SettingsContent() { const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [isLinking, setIsLinking] = useState(false); const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true; - const { - interfaceTheme, - bodyFontPreference, - headingFontPreference, - radiusPreference, - sidebarStyle, - colorMode, - colorTheme, - customColor, - brandName, - brandTagline, - brandLogoText, - brandIcon, - pdfTemplate, - pdfAccentColor, - pdfFooterText, - pdfShowLogo, - pdfShowPageNumbers, - updateAppearance, - updateAppearanceDebounced, - isUpdating: appearanceUpdating, - } = useAppearance(); - const activePreset = themePresets[interfaceTheme]; - const themeModified = - activePreset.bodyFontPreference !== bodyFontPreference || - activePreset.headingFontPreference !== headingFontPreference || - activePreset.colorTheme !== colorTheme || - activePreset.radiusPreference !== radiusPreference || - activePreset.sidebarStyle !== sidebarStyle || - activePreset.pdfTemplate !== pdfTemplate || - activePreset.pdfAccentColor !== pdfAccentColor; - const customColorValue = customColor ?? "142.1 76.2% 36.3%"; - const selectAccent = (nextColorTheme: ColorTheme) => { - updateAppearance({ - colorTheme: nextColorTheme, - ...(nextColorTheme === "custom" ? { customColor: customColorValue } : {}), - }); + const { colorMode, updateAppearance, isUpdating: appearanceUpdating } = + useAppearance(); + const utils = api.useUtils(); + const { data: pdfSettings } = api.settings.getPdfSettings.useQuery(); + const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({ + onSuccess: async () => { + await utils.settings.getPdfSettings.invalidate(); + toast.success("Invoice PDF settings updated"); + }, + onError: (error: { message: string }) => { + toast.error(`Failed to update PDF settings: ${error.message}`); + }, + }); + + const savePdfSettings = (patch: { + pdfTemplate?: PdfTemplate; + pdfAccentColor?: string; + pdfFooterText?: string; + pdfShowLogo?: boolean; + pdfShowPageNumbers?: boolean; + }) => { + updatePdfSettingsMutation.mutate(patch); }; const handleLinkAuthentik = async () => { @@ -320,8 +231,9 @@ export function SettingsContent() { const importDataMutation = api.settings.importData.useMutation({ onSuccess: (result) => { + const { imported } = result; toast.success( - `Data imported successfully! Added ${result.imported.clients} clients, ${result.imported.businesses} businesses, and ${result.imported.invoices} invoices.`, + `Data imported successfully! Added ${imported.clients} clients, ${imported.businesses} businesses, ${imported.invoices} invoices, ${imported.expenses} expenses, ${imported.timeEntries} time entries, and ${imported.recurringInvoices} recurring invoices.`, ); setImportData(""); setIsImportDialogOpen(false); @@ -494,16 +406,16 @@ export function SettingsContent() { ]; return ( - - - General - Preferences - Data - API - + + + General + Preferences + Data + API + - -
+ +
{/* Profile Section */} @@ -724,9 +636,9 @@ export function SettingsContent() { - + - + @@ -734,448 +646,49 @@ export function SettingsContent() { Appearance - Select the app skin, color mode, accent, and font stack. + Choose light, dark, or match your system setting. - {!isAdmin ? ( - -

- Platform appearance and branding are managed by an - administrator. + +

+ + +

+ { + colorModes.find( + (modeOption) => modeOption.value === colorMode, + )?.description + }

- - ) : ( - -
-
-

Brand

-

- Public-facing name, logo text, and short product tagline. -

-
-
-
- - - updateAppearanceDebounced({ - brandName: event.target.value, - }) - } - /> -
- -
- - - updateAppearanceDebounced({ - brandLogoText: event.target.value, - }) - } - /> -
- -
- - - updateAppearanceDebounced({ - brandIcon: event.target.value, - }) - } - /> -
- -
- - - updateAppearanceDebounced({ - brandTagline: event.target.value, - }) - } - /> -
-
-
- -
-
-

Theme

-

- Presets establish the broad visual language; color mode and - accent can still be tuned independently. -

-
-
-
-
- -
- {themeModified && ( - - modified - - )} - {themeModified && ( - - )} -
-
- -

- Applies the theme, fonts, accent, corner radius, - navigation chrome, and PDF defaults. -

-

- { - interfaceThemes.find( - (themeOption) => themeOption.value === interfaceTheme, - )?.description - } -

-
- -
- - -

- { - colorModes.find( - (modeOption) => modeOption.value === colorMode, - )?.description - } -

-
-
-
- -
-
-

Typography

-

- Body and heading fonts are separate so white-label installs - can feel native without losing hierarchy. -

-
-
-
- - -

- { - bodyFontPreferences.find( - (fontOption) => - fontOption.value === bodyFontPreference, - )?.description - } -

-
- -
- - -

- { - headingFontPreferences.find( - (fontOption) => - fontOption.value === headingFontPreference, - )?.description - } -

-
-
-
- -
-
-

Color

-

- Accent controls primary actions, focus rings, and branded - highlights. -

-
-
- -
- {colorThemes.map((themeOption) => ( - - ))} - -
- {colorTheme === "custom" && ( -
- undefined} - onChange={(value) => { - if (isFullHexColor(value)) { - updateAppearanceDebounced({ - colorTheme: "custom", - customColor: hexToHslChannels(value), - }); - } - }} - className="mt-0" - /> - - updateAppearanceDebounced({ - colorTheme: "custom", - customColor: event.target.value, - }) - } - placeholder="142.1 76.2% 36.3%" - /> -
- )} -

- Custom values use HSL channels, for example 142.1 76.2% - 36.3%. -

-
-
- -
-
-

Layout

-

- Control global rounding and whether navigation floats or - sits flush with the viewport. -

-
-
-
- - -

- { - radiusPreferences.find( - (radiusOption) => - radiusOption.value === radiusPreference, - )?.description - } -

-
- -
- - -

- { - sidebarStyles.find( - (styleOption) => styleOption.value === sidebarStyle, - )?.description - } -

-
-
-
- - {appearanceUpdating && ( -

- Saving appearance... -

- )} -
- )} +
+ {appearanceUpdating && ( +

Saving...

+ )} +
{isAdmin && ( @@ -1191,7 +704,12 @@ export function SettingsContent() { -
+
@@ -1200,12 +718,13 @@ export function SettingsContent() { PDF Template - updateAppearanceDebounced({ - pdfFooterText: event.target.value, - }) + savePdfSettings({ pdfFooterText: event.target.value }) } + disabled={updatePdfSettingsMutation.isPending} />
@@ -1259,10 +775,11 @@ export function SettingsContent() {

- updateAppearance({ pdfShowLogo: Boolean(checked) }) + savePdfSettings({ pdfShowLogo: Boolean(checked) }) } + disabled={updatePdfSettingsMutation.isPending} aria-label="Toggle PDF logo" />
@@ -1275,12 +792,13 @@ export function SettingsContent() {

- updateAppearance({ + savePdfSettings({ pdfShowPageNumbers: Boolean(checked), }) } + disabled={updatePdfSettingsMutation.isPending} aria-label="Toggle PDF page numbers" />
@@ -1288,13 +806,14 @@ export function SettingsContent() {
@@ -1397,9 +916,9 @@ export function SettingsContent() { -
+ - + {/* Data Overview */} @@ -1621,7 +1140,7 @@ export function SettingsContent() { {/* Delete Account (Danger Zone) */} - + @@ -1672,11 +1191,11 @@ export function SettingsContent() { - + - + - -
+ + ); } diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 92cd066..87ff672 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -1,16 +1,16 @@ import { Suspense } from "react"; import { HydrateClient } from "~/trpc/server"; -import { PageHeader } from "~/components/layout/page-header"; +import { DashboardPageHeader } from "~/components/layout/page-header"; +import { DashboardPage } from "~/components/layout/dashboard-page"; import { DataTableSkeleton } from "~/components/data/data-table"; import { SettingsContent } from "./_components/settings-content"; export default async function SettingsPage() { return ( -
- + @@ -18,6 +18,6 @@ export default async function SettingsPage() { -
+ ); } diff --git a/src/app/dashboard/time-clock/page.tsx b/src/app/dashboard/time-clock/page.tsx index 12f9d3d..aef6833 100644 --- a/src/app/dashboard/time-clock/page.tsx +++ b/src/app/dashboard/time-clock/page.tsx @@ -1,5 +1,6 @@ import { HydrateClient, api } from "~/trpc/server"; import { DashboardPageHeader } from "~/components/layout/page-header"; +import { DashboardPage } from "~/components/layout/dashboard-page"; import { TimeClockPanel } from "~/components/time-clock/time-clock-panel"; export default async function TimeClockPage({ @@ -17,7 +18,7 @@ export default async function TimeClockPage({ } return ( -
+ -
+ ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dfa2377..dc9bad6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,14 +4,7 @@ import { type Metadata } from "next"; import localFont from "next/font/local"; import { Toaster } from "~/components/ui/sonner"; -import { - brand, - defaultBodyFontPreference, - defaultHeadingFontPreference, - defaultInterfaceTheme, - defaultRadiusPreference, - defaultSidebarStyle, -} from "~/lib/branding"; +import { brand } from "~/lib/branding"; import { UmamiScript } from "~/components/analytics/umami-script"; import { BrandBackground } from "~/components/layout/brand-background"; @@ -34,23 +27,6 @@ const playfair = localFont({ display: "swap", }); -const frutiger = localFont({ - src: [ - { - path: "../../public/fonts/frutiger/Frutiger.ttf", - weight: "400", - style: "normal", - }, - { - path: "../../public/fonts/frutiger/Frutiger_bold.ttf", - weight: "700", - style: "normal", - }, - ], - variable: "--font-frutiger", - display: "swap", -}); - const geistMono = localFont({ src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf", variable: "--font-geist-mono", @@ -64,14 +40,8 @@ export default function RootLayout({