Compare commits

...
2 Commits
Author SHA1 Message Date
soconnorandCursor 3fb61ff4dd Polish onboarding, invoices, and time clock while promoting the first registrant to admin.
Refresh onboarding wizard and shell, tighten invoice edit/detail flows, align timer widgets with the redesigned clock panel, and assign admin role on first signup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 03:40:25 -04:00
soconnorandCursor c53f2e6c4d 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 <cursoragent@cursor.com>
2026-06-26 03:08:22 -04:00
100 changed files with 3808 additions and 3859 deletions
+1 -15
View File
@@ -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)
# =============================================================================
+2
View File
@@ -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
@@ -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";
+11
View File
@@ -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"
);
+14
View File
@@ -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
}
]
}
+1 -1
View File
@@ -5,7 +5,7 @@ import drizzle from "eslint-plugin-drizzle";
export default tseslint.config(
{
ignores: [".next"],
ignores: [".next", "scripts/**"],
},
...nextCoreWebVitals,
{
+6 -2
View File
@@ -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 <noreply@beenvoice.com>",
from: `beenvoice <noreply@${fromDomain}>`,
to: email,
subject: emailTemplate.subject,
html: emailTemplate.html,
+11
View File
@@ -3,6 +3,8 @@ 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 { resolveNewUserRole } from "~/lib/first-admin";
import { env } from "~/env";
import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema";
@@ -118,12 +120,15 @@ export async function POST(request: NextRequest) {
const hashedPassword = await bcrypt.hash(password, 12);
await db.transaction(async (tx) => {
const role = await resolveNewUserRole(tx);
const [user] = await tx
.insert(users)
.values({
name: `${firstName} ${lastName}`,
email: normalizedEmail,
password: hashedPassword,
role,
})
.returning({ id: users.id });
@@ -161,6 +166,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 },
+2 -1
View File
@@ -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}`,
+2 -210
View File
@@ -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<HTMLFormElement>) {
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 (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div>
<Card className="mx-auto w-full max-w-md border-border/50 bg-background/80 backdrop-blur-xl">
<CardContent className="p-8">
<div className="space-y-6">
<div className="space-y-2">
<Logo size="lg" />
<div>
<h1 className="font-heading text-2xl font-bold">Create your account</h1>
<p className="text-muted-foreground text-sm">Get started today</p>
</div>
</div>
<form onSubmit={handleRegister} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="firstName"
name="firstName"
type="text"
required
autoFocus
autoComplete="given-name"
className="h-10 pl-10"
placeholder="John"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="lastName"
name="lastName"
type="text"
required
autoComplete="family-name"
className="h-10 pl-10"
placeholder="Doe"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
name="email"
type="email"
required
autoComplete="email"
className="h-10 pl-10"
placeholder="you@example.com"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
name="password"
type="password"
required
minLength={8}
autoComplete="new-password"
className="h-10 pl-10"
placeholder="••••••••"
/>
</div>
<p className="text-muted-foreground text-xs">At least 8 characters</p>
</div>
<Button type="submit" className="h-10 w-full" disabled={loading}>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2" />
<span>Creating account</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Create Account</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
<p className="text-muted-foreground text-center text-sm">
Already have an account?{" "}
<a href="/auth/signin" className="text-foreground font-medium hover:underline">
Sign in
</a>
</p>
<LegalAgreementNotice action="creating an account" />
</div>
</CardContent>
</Card>
</div>
);
}
import { RegisterForm } from "./register-form";
export default function RegisterPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<RegisterForm />
</Suspense>
);
return <RegisterForm />;
}
+201
View File
@@ -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 (
<AuthPageShell>
<AuthCard>
<AuthCardHeader
title="Create your account"
description="Get started with your workspace"
/>
<form onSubmit={handleRegister} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="firstName">First name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="firstName"
name="firstName"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
autoFocus
autoComplete="given-name"
className="h-11 pl-10"
placeholder="John"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="lastName"
name="lastName"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
autoComplete="family-name"
className="h-11 pl-10"
placeholder="Doe"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="h-11 pl-10"
placeholder="you@example.com"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="h-11 pl-10"
placeholder="••••••••"
/>
</div>
<p className="text-muted-foreground text-xs">At least 8 characters</p>
</div>
<Button type="submit" className="h-11 w-full" disabled={loading}>
{loading ? "Creating account…" : "Create account"}
{!loading && <ArrowRight className="ml-2 h-4 w-4" />}
</Button>
</form>
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{" "}
<Link
href="/auth/signin"
className="text-foreground font-medium hover:underline"
>
Sign in
</Link>
</p>
<LegalAgreementNotice action="creating an account" className="mt-5" />
</AuthCard>
</AuthPageShell>
);
}
@@ -7,13 +7,37 @@ import { Card, CardContent } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Square, Clock } from "lucide-react";
import { toast } from "sonner";
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
import {
describeClockOutOutcome,
formatElapsedSeconds,
formatRunningTimerLabel,
} 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<ReturnType<typeof setInterval> | null>(null);
@@ -56,9 +80,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,24 +90,119 @@ export function ActiveTimerWidget() {
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: null;
return (
<Card className="border-primary/30 bg-primary/5">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<span className="relative flex h-3 w-3 flex-shrink-0">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-3 w-3 rounded-full" />
</span>
const description = formatRunningTimerLabel(running.description);
<div className="min-w-0 flex-1">
const renderStopButton = (className?: string) => (
<Button
variant="destructive"
size="sm"
onClick={() => clockOut.mutate({})}
disabled={clockOut.isPending}
className={cn(compact && "h-8 px-2", className)}
>
<Square className={cn("h-3.5 w-3.5", !compact && "mr-1.5")} />
{!compact && (clockOut.isPending ? "Stopping…" : "Stop")}
</Button>
);
if (compact) {
return (
<div className="ml-auto flex min-w-0 items-center gap-1.5">
<Link
href="/dashboard/time-clock"
className="border-primary/30 bg-primary/5 flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1"
>
<span className="relative flex h-2 w-2 shrink-0">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" />
</span>
<span className="text-primary truncate font-mono text-sm font-bold tabular-nums">
{formatElapsedSeconds(elapsed)}
</span>
</Link>
{renderStopButton("shrink-0")}
</div>
);
}
if (collapsed) {
return (
<div className="flex justify-center">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/dashboard/time-clock"
className="border-primary/30 bg-primary/5 relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors hover:bg-primary/10"
>
<Clock className="text-primary h-5 w-5" />
<span className="absolute top-1 right-1 flex h-2 w-2">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" />
</span>
</Link>
</TooltipTrigger>
<TooltipContent
side="right"
className="bg-popover text-popover-foreground border-border max-w-56 space-y-2 border p-3 text-sm [&>svg]:bg-popover [&>svg]:fill-popover"
>
<p className="text-sm font-medium">
{running.description || (
<span className="text-muted-foreground italic">No description</span>
)}
{description}
{running.client && (
<span className="text-muted-foreground font-normal"> · {running.client.name}</span>
<span className="text-muted-foreground font-normal">
{" "}
· {running.client.name}
</span>
)}
</p>
<p className="text-primary font-mono text-lg font-bold tabular-nums">
{formatElapsedSeconds(elapsed)}
</p>
{invoiceLabel ? (
<p className="text-muted-foreground text-xs">
Billing to{" "}
<Link
href={`/dashboard/invoices/${running.invoice!.id}`}
className="text-primary hover:underline"
>
{invoiceLabel}
</Link>
</p>
) : (
<p className="text-muted-foreground text-xs">No invoice selected</p>
)}
<div className="flex gap-2 pt-1">
<Button variant="outline" size="sm" asChild className="h-8 flex-1">
<Link href="/dashboard/time-clock">Open</Link>
</Button>
{renderStopButton()}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
return (
<Card className="border-primary/30 bg-primary/5">
<CardContent className="flex flex-col gap-3 p-3">
<div className="flex items-start gap-2">
<span className="relative mt-1 flex h-2.5 w-2.5 flex-shrink-0">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug font-medium">
{description}
{running.client && (
<span className="text-muted-foreground font-normal">
{" "}
· {running.client.name}
</span>
)}
</p>
<p className="text-muted-foreground mt-1 text-xs leading-snug">
{invoiceLabel ? (
<>
Billing to{" "}
@@ -106,27 +222,21 @@ export function ActiveTimerWidget() {
</Link>
</p>
</div>
</div>
<span className="text-primary font-mono text-2xl font-bold tabular-nums">
<div className="flex flex-col items-center gap-2">
<span className="text-primary text-center font-mono text-xl font-bold tabular-nums">
{formatElapsedSeconds(elapsed)}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild>
<div className="flex w-full flex-col gap-1.5">
<Button variant="outline" size="sm" asChild className="h-8 w-full">
<Link href="/dashboard/time-clock">
<Clock className="mr-1.5 h-3.5 w-3.5" />
<Clock className="mr-1 h-3.5 w-3.5" />
Open
</Link>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => clockOut.mutate({})}
disabled={clockOut.isPending}
>
<Square className="mr-1.5 h-3.5 w-3.5" />
{clockOut.isPending ? "Stopping…" : "Stop"}
</Button>
{renderStopButton("w-full")}
</div>
</div>
</CardContent>
</Card>
@@ -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 (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<div className="flex items-center space-x-2">
<Icon className="text-muted-foreground h-5 w-5" />
<p className="text-muted-foreground text-sm font-medium">{title}</p>
</div>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<CardTitle className="text-muted-foreground flex items-center gap-2 text-sm font-medium">
<Icon className="h-4 w-4" />
{title}
</CardTitle>
<div
className="flex items-center space-x-1 text-xs"
style={{
color: isNeutral
? "hsl(var(--muted-foreground))"
className={cn(
"flex items-center gap-1 text-xs font-medium",
isNeutral
? "text-muted-foreground"
: isPositive
? "oklch(var(--chart-2))"
: "oklch(var(--chart-3))",
}}
? "text-emerald-600 dark:text-emerald-400"
: "text-amber-600 dark:text-amber-400",
)}
>
<TrendIcon className="h-3 w-3" />
<span>{change}</span>
</div>
</div>
<div className="space-y-1">
<p className="animate-count-up text-2xl font-bold">{displayValue}</p>
<p className="text-muted-foreground text-xs">{description}</p>
<span className="font-mono tabular-nums">{change}</span>
</div>
</CardHeader>
<CardContent className="pt-0">
<p className="font-mono text-2xl font-semibold tracking-tight tabular-nums">
{value}
</p>
<CardDescription className="mt-1">{description}</CardDescription>
</CardContent>
</Card>
);
@@ -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 (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{data.name}</p>
<p className="text-sm">
<p className="font-mono text-sm tabular-nums">
{data.count} invoice{data.count !== 1 ? "s" : ""}
</p>
<p className="text-sm">{formatChartCurrency(data.value)}</p>
<p className="font-mono text-sm tabular-nums">
{formatChartCurrency(data.value)}
</p>
</div>
);
}
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<string, { status: string; count: number; value: number }>,
);
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 (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
@@ -109,11 +82,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
return (
<div className="space-y-4">
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveChart height={192} className="h-48">
<PieChart>
<Pie
data={chartData}
data={data}
cx="50%"
cy="50%"
innerRadius={40}
@@ -124,7 +96,7 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
animationDuration={pieAnimationDuration}
animationEasing="ease-out"
>
{chartData.map((entry, index) => (
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={
@@ -135,12 +107,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
</Pie>
<Tooltip content={<StatusTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
</ResponsiveChart>
{/* Legend */}
<div className="space-y-2">
{chartData.map((item) => (
{data.map((item) => (
<div key={item.status} className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div
@@ -153,8 +123,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
<span className="text-sm font-medium">{item.name}</span>
</div>
<div className="text-right">
<p className="text-sm font-medium">{item.count}</p>
<p className="text-muted-foreground text-xs">
<p className="font-mono text-sm font-medium tabular-nums">
{item.count}
</p>
<p className="text-muted-foreground font-mono text-xs tabular-nums">
{formatChartCurrency(item.value)}
</p>
</div>
@@ -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 (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p>
<div className="space-y-1 text-sm">
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
<p className="text-primary/80">Pending: {data.pendingInvoices}</p>
<p className="text-destructive">Overdue: {data.overdueInvoices}</p>
<p className="text-muted-foreground">Draft: {data.draftInvoices}</p>
<p className="text-foreground border-t pt-1 font-medium">
Total: {data.totalInvoices}
<p className="text-primary font-medium font-mono tabular-nums">
Paid: {chartDatum.paidInvoices}
</p>
<p className="text-primary/80 font-mono tabular-nums">
Pending: {chartDatum.pendingInvoices}
</p>
<p className="text-destructive font-mono tabular-nums">
Overdue: {chartDatum.overdueInvoices}
</p>
<p className="text-muted-foreground font-mono tabular-nums">
Draft: {chartDatum.draftInvoices}
</p>
<p className="text-foreground border-t pt-1 font-medium font-mono tabular-nums">
Total: {chartDatum.totalInvoices}
</p>
</div>
</div>
@@ -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 (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
@@ -149,9 +87,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
return (
<div className="space-y-4">
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<ResponsiveChart height={192} className="h-48">
<BarChart data={data}>
<XAxis
dataKey="monthLabel"
axisLine={false}
@@ -161,7 +98,11 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
tick={{
fontSize: 12,
fill: "var(--muted-foreground)",
fontFamily: "var(--font-mono)",
}}
/>
<Tooltip content={<MonthlyMetricsTooltip />} />
<Bar
@@ -202,10 +143,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
animationEasing="ease-out"
/>
</BarChart>
</ResponsiveContainer>
</div>
</ResponsiveChart>
{/* Legend */}
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">
<div className="flex items-center space-x-2">
<div
@@ -3,11 +3,11 @@
import {
Area,
AreaChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ResponsiveChart } from "~/components/charts/responsive-chart";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface RevenueChartProps {
@@ -41,7 +41,10 @@ const CustomTooltip = ({
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p>
<p style={{ color: "hsl(0, 0%, 60%)" }}>
<p
className="font-mono tabular-nums"
style={{ color: "hsl(0, 0%, 60%)" }}
>
Revenue: {formatCurrency(data.revenue)}
</p>
<p className="text-muted-foreground text-sm">
@@ -84,8 +87,7 @@ export function RevenueChart({ data }: RevenueChartProps) {
}
return (
<div className="h-48 w-full md:h-64">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveChart height={256} className="h-48 md:h-64">
<AreaChart data={chartData}>
<defs>
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
@@ -110,7 +112,11 @@ export function RevenueChart({ data }: RevenueChartProps) {
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
tick={{
fontSize: 12,
fill: "hsl(var(--muted-foreground))",
fontFamily: "var(--font-mono)",
}}
tickFormatter={formatCurrency}
/>
<Tooltip content={<CustomTooltip />} />
@@ -127,7 +133,6 @@ export function RevenueChart({ data }: RevenueChartProps) {
animationEasing="ease-out"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</ResponsiveChart>
);
}
+23 -5
View File
@@ -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 (
<div className="page-enter space-y-6">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Administration"
description="Manage account access and platform administration"
variant="gradient"
/>
<HydrateClient>
@@ -18,6 +36,6 @@ export default async function AdministrationPage() {
<AdministrationContent />
</Suspense>
</HydrateClient>
</div>
</DashboardPage>
);
}
+13 -8
View File
@@ -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 (
<div className="space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
description="View business details and information"
variant="gradient"
>
<Button asChild variant="outline" className="shadow-sm">
<Link href="/dashboard/entities?tab=businesses">
@@ -61,9 +66,9 @@ export default async function BusinessDetailPage({
<span>Edit Business</span>
</Link>
</Button>
</PageHeader>
</DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
{/* Business Information Card */}
<div className="lg:col-span-2">
<Card className="bg-card border-border border">
@@ -265,7 +270,7 @@ export default async function BusinessDetailPage({
</div>
{/* Settings & Actions Card */}
<div className="space-y-6">
<div className={cn("flex flex-col", dashboardGapClass)}>
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -323,6 +328,6 @@ export default async function BusinessDetailPage({
</Card>
</div>
</div>
</div>
</DashboardPage>
);
}
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "~/components/ui/button";
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
import { Building, Pencil, Trash2, ExternalLink } from "lucide-react";
import { Building, Pencil, Trash2, ExternalLink, Plus } from "lucide-react";
import { useState } from "react";
import {
Dialog,
@@ -208,6 +208,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
data={searchableBusinesses}
searchKey="searchValue"
searchPlaceholder="Search by name or nickname..."
emptyTitle="Create your first business"
emptyDescription="Set up a business profile for invoices, branding, and tax details."
emptyIcon={<Building className="h-6 w-6" />}
emptyAction={
<Button asChild>
<Link href="/dashboard/businesses/new">
<Plus className="mr-2 h-4 w-4" />
Add business
</Link>
</Button>
}
onRowClick={handleRowClick}
/>
+13 -8
View File
@@ -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 (
<div className="space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title={client.name}
description="View client details and information"
variant="gradient"
>
<Button asChild variant="outline" className="shadow-sm">
<Link href="/dashboard/entities?tab=clients">
@@ -75,9 +80,9 @@ export default async function ClientDetailPage({
<span>Edit Client</span>
</Link>
</Button>
</PageHeader>
</DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
{/* Client Information Card */}
<div className="lg:col-span-2">
<Card className="bg-card border-border border">
@@ -173,7 +178,7 @@ export default async function ClientDetailPage({
</div>
{/* Stats Card */}
<div className="space-y-6">
<div className={cn("flex flex-col", dashboardGapClass)}>
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -275,6 +280,6 @@ export default async function ClientDetailPage({
)}
</div>
</div>
</div>
</DashboardPage>
);
}
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "~/components/ui/button";
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
import { UserPlus, Pencil, Trash2 } from "lucide-react";
import { UserPlus, Pencil, Trash2, Plus, Users } from "lucide-react";
import { useState } from "react";
import {
Dialog,
@@ -179,6 +179,17 @@ export function ClientsDataTable({
data={clients}
searchKey="name"
searchPlaceholder="Search clients..."
emptyTitle="Create your first client"
emptyDescription="Add clients to bill them and keep contact details in one place."
emptyIcon={<Users className="h-6 w-6" />}
emptyAction={
<Button asChild>
<Link href="/dashboard/clients/new">
<Plus className="mr-2 h-4 w-4" />
Add client
</Link>
</Button>
}
onRowClick={handleRowClick}
/>
@@ -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 (
<div className="space-y-6">
<PageHeader
<>
<DashboardPageHeader
title="Entities"
description="Clients you bill and businesses you send from"
variant="gradient"
>
<Button asChild variant="default" className="hover-lift shadow-md">
<Link href={addHref}>
@@ -39,22 +55,24 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
<span>{addLabel}</span>
</Link>
</Button>
</PageHeader>
</DashboardPageHeader>
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="clients">Clients</TabsTrigger>
<TabsTrigger value="businesses">Businesses</TabsTrigger>
</TabsList>
<PageTabs value={tab} onValueChange={handleTabChange}>
<PageTabsList>
<PageTabsTrigger value="clients">Clients</PageTabsTrigger>
<PageTabsTrigger value="businesses">Businesses</PageTabsTrigger>
</PageTabsList>
<TabsContent value="clients" className="mt-6">
<ClientsTable />
</TabsContent>
<PageTabsContent value="clients">
{tab === "clients" ? <ClientsDataTable clients={clients} /> : null}
</PageTabsContent>
<TabsContent value="businesses" className="mt-6">
<BusinessesTable />
</TabsContent>
</Tabs>
</div>
<PageTabsContent value="businesses">
{tab === "businesses" ? (
<BusinessesDataTable businesses={businesses} />
) : null}
</PageTabsContent>
</PageTabs>
</>
);
}
+13 -12
View File
@@ -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 (
<div className="page-enter space-y-6">
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={5} rows={8} />}>
<EntitiesView initialTab={initialTab} />
</Suspense>
</HydrateClient>
</div>
<DashboardPage>
<EntitiesView
initialTab={initialTab}
clients={clients}
businesses={businesses}
/>
</DashboardPage>
);
}
+19 -14
View File
@@ -2,7 +2,9 @@
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 { EmptyState } from "~/components/layout/page-layout";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
@@ -145,11 +147,10 @@ export default function ExpensesPage() {
.reduce((s, e) => s + e.amount, 0);
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Expenses"
description="Track billable and non-billable expenses"
variant="gradient"
>
<Button
onClick={handleOpen}
@@ -158,10 +159,9 @@ export default function ExpensesPage() {
>
<Plus className="mr-2 h-5 w-5" /> Add Expense
</Button>
</PageHeader>
</DashboardPageHeader>
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className={dashboardStatGridClass}>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
@@ -215,12 +215,17 @@ export default function ExpensesPage() {
Loading
</div>
) : expenses.length === 0 ? (
<div className="p-8 text-center">
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground text-sm">
No expenses yet. Add your first expense.
</p>
</div>
<EmptyState
icon={<Receipt className="h-6 w-6" />}
title="Create your first expense"
description="Track billable costs, reimbursements, and tax-deductible spending."
action={
<Button onClick={handleOpen}>
<Plus className="mr-2 h-4 w-4" />
Add expense
</Button>
}
/>
) : (
<div className="divide-y">
{expenses.map((expense) => (
@@ -476,6 +481,6 @@ export default function ExpensesPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardPage>
);
}
@@ -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 (
<div className="space-y-6 pb-24">
{/* Header */}
<PageHeader
<DashboardPage className="pb-24">
<DashboardPageHeader
title="Loading..."
description="View and manage invoice information"
variant="gradient"
>
<Skeleton className="h-10 w-10 sm:w-32" />
<Skeleton className="h-10 w-24" />
</PageHeader>
</DashboardPageHeader>
{/* Content */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left Column */}
<div className="space-y-6 lg:col-span-2">
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
{/* Invoice Header Skeleton */}
<Card>
<CardContent className="p-4 sm:p-6">
@@ -155,7 +157,7 @@ export function InvoiceDetailsSkeleton() {
</div>
{/* Right Column - Actions */}
<div className="space-y-6">
<div className={cn("flex flex-col", dashboardGapClass)}>
<Card className="lg:sticky lg:top-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -172,6 +174,6 @@ export function InvoiceDetailsSkeleton() {
</Card>
</div>
</div>
</div>
</DashboardPage>
);
}
@@ -1,9 +1,6 @@
"use client";
import Link from "next/link";
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
import { Button } from "~/components/ui/button";
import { ExternalLink } from "lucide-react";
interface InvoiceTimerCardProps {
invoiceId: string;
@@ -12,18 +9,10 @@ interface InvoiceTimerCardProps {
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
return (
<div className="space-y-3">
<TimeClockPanel
compact
defaultClientId={clientId}
defaultInvoiceId={invoiceId}
/>
<Button variant="outline" size="sm" className="w-full" asChild>
<Link href={`/dashboard/time-clock?clientId=${clientId}&invoiceId=${invoiceId}`}>
Open full time clock
<ExternalLink className="ml-2 h-3.5 w-3.5" />
</Link>
</Button>
</div>
);
}
@@ -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) {
+17 -7
View File
@@ -1,12 +1,22 @@
"use client";
import { useParams } from "next/navigation";
import { redirect } from "next/navigation";
import InvoiceForm from "~/components/forms/invoice-form";
import { api } from "~/trpc/server";
export default function InvoiceFormPage() {
const params = useParams();
const id = params.id as string;
interface EditInvoicePageProps {
params: Promise<{ id: string }>;
}
export default async function EditInvoicePage({ params }: EditInvoicePageProps) {
const { id } = await params;
try {
const invoice = await api.invoices.getById({ id });
if (invoice.status !== "draft") {
redirect(`/dashboard/invoices/${id}?editBlocked=1`);
}
} catch {
redirect("/dashboard/invoices");
}
// Pass the actual id, let the form component handle the logic
return <InvoiceForm invoiceId={id} />;
}
+27 -10
View File
@@ -20,11 +20,17 @@ import {
User,
} from "lucide-react";
import Link from "next/link";
import { notFound, useParams, useRouter } from "next/navigation";
import { notFound, useParams, useRouter, useSearchParams } 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";
@@ -83,6 +89,7 @@ function daysSince(date: Date) {
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [recordPaymentOpen, setRecordPaymentOpen] = useState(false);
const [reminderOpen, setReminderOpen] = useState(false);
@@ -100,6 +107,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
api.payments.getByInvoice.useQuery({ invoiceId });
const utils = api.useUtils();
useEffect(() => {
if (searchParams.get("editBlocked") === "1") {
toast.error("Only draft invoices can be edited");
router.replace(`/dashboard/invoices/${invoiceId}`);
}
}, [searchParams, invoiceId, router]);
const invalidate = () => {
void utils.invoices.getById.invalidate({ id: invoiceId });
void utils.payments.getByInvoice.invalidate({ invoiceId });
@@ -224,24 +238,25 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
};
return (
<div className="page-enter space-y-6 pb-24">
<PageHeader
<DashboardPage className="pb-24">
<DashboardPageHeader
title="Invoice Details"
description="View and manage invoice information"
variant="gradient"
>
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
{storedStatus === "draft" ? (
<Button asChild variant="default" className="hover-lift">
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Edit className="mr-2 h-5 w-5" />
Edit
</Link>
</Button>
</PageHeader>
) : null}
</DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
{/* Left Column */}
<div className="space-y-6 lg:col-span-2">
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
{/* Invoice Header */}
<Card>
<CardContent className="p-4 sm:p-6">
@@ -531,7 +546,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</div>
{/* Right Column - Actions */}
<div className="space-y-6">
<div className={cn("flex flex-col", dashboardGapClass)}>
{storedStatus === "draft" && (
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
)}
@@ -544,12 +559,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{storedStatus === "draft" ? (
<Button asChild variant="secondary" className="w-full">
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Invoice
</Link>
</Button>
) : null}
{invoice.items && invoice.client && (
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
@@ -833,7 +850,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardPage>
);
}
+41 -33
View File
@@ -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 (
<div className="space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title="Loading..."
description="Loading invoice email"
variant="gradient"
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className={cn("lg:col-span-2", dashboardGapClass, "flex flex-col")}>
<div className="bg-muted h-96 animate-pulse" />
</div>
<div className="space-y-6">
<div className={cn(dashboardGapClass, "flex flex-col")}>
<div className="bg-muted h-64 animate-pulse" />
</div>
</div>
</div>
</DashboardPage>
);
}
@@ -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 (
<div className="page-enter space-y-6">
<DashboardPage>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>Invoice not found.</AlertDescription>
</Alert>
</div>
</DashboardPage>
);
}
return (
<div className="page-enter space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title={`Send Invoice ${invoice.invoiceNumber}`}
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat(
"en-US",
@@ -313,7 +324,6 @@ export default function SendEmailPage() {
day: "numeric",
},
).format(new Date())}`}
variant="gradient"
>
<Button
variant="outline"
@@ -322,7 +332,7 @@ export default function SendEmailPage() {
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Invoice
</Button>
</PageHeader>
</DashboardPageHeader>
{/* Warning for missing email */}
{(!toEmail || toEmail.trim() === "") && (
@@ -336,22 +346,21 @@ export default function SendEmailPage() {
)}
{/* Main Content */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className="lg:col-span-2">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="compose" className="flex items-center gap-2">
<PageTabs value={activeTab} onValueChange={setActiveTab}>
<PageTabsList>
<PageTabsTrigger value="compose" className="gap-2">
<Edit3 className="h-4 w-4" />
Compose
</TabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2">
</PageTabsTrigger>
<PageTabsTrigger value="preview" className="gap-2">
<Eye className="h-4 w-4" />
Preview
</TabsTrigger>
</TabsList>
</PageTabsTrigger>
</PageTabsList>
<div className="mt-6">
<TabsContent value="compose" className="space-y-6">
<PageTabsContent value="compose">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -387,9 +396,9 @@ export default function SendEmailPage() {
)}
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
<TabsContent value="preview" className="space-y-6">
<PageTabsContent value="preview">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -413,13 +422,12 @@ export default function SendEmailPage() {
</div>
</CardContent>
</Card>
</TabsContent>
</div>
</Tabs>
</PageTabsContent>
</PageTabs>
</div>
{/* Sidebar */}
<div className="space-y-6">
<div className={cn(dashboardGapClass, "flex flex-col")}>
{/* Invoice Summary */}
<Card>
<CardHeader>
@@ -644,6 +652,6 @@ export default function SendEmailPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardPage>
);
}
@@ -31,6 +31,7 @@ import {
CheckCircle,
Send,
ChevronDown,
Plus,
} from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
@@ -266,16 +267,30 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<Eye className="h-3.5 w-3.5" />
</Button>
</Link>
{invoice.status === "draft" ? (
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
title="Edit invoice"
>
<Edit className="h-3.5 w-3.5" />
</Button>
</Link>
) : (
<Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
disabled
title="Only draft invoices can be edited"
>
<Edit className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -322,6 +337,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
searchPlaceholder="Search invoices..."
initialSorting={[{ id: "issueDate", desc: true }]}
filterableColumns={filterableColumns}
emptyTitle="Create your first invoice"
emptyDescription="Send professional invoices and track payments from one place."
emptyIcon={<FileText className="h-6 w-6" />}
emptyAction={
<Button asChild>
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create invoice
</Link>
</Button>
}
onRowClick={(invoice) =>
router.push(`/dashboard/invoices/${invoice.id}`)
}
+8 -7
View File
@@ -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 (
<div className="grid gap-6 lg:grid-cols-2">
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
{/* Required Format */}
<Card className="bg-card border-border border">
<CardHeader>
@@ -203,11 +205,10 @@ function FileFormatHelp() {
export default async function ImportPage() {
return (
<div className="space-y-8">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Import Time Entries"
description="Upload CSV files to create invoices from your time tracking data"
variant="gradient"
>
<Link href="/dashboard/invoices">
<Button variant="outline" size="lg">
@@ -215,7 +216,7 @@ export default async function ImportPage() {
Back to Invoices
</Button>
</Link>
</PageHeader>
</DashboardPageHeader>
<HydrateClient>
{/* Main CSV Import Component */}
@@ -230,6 +231,6 @@ export default async function ImportPage() {
{/* Important Notes */}
<ImportantNotes />
</HydrateClient>
</div>
</DashboardPage>
);
}
+7 -13
View File
@@ -2,8 +2,9 @@ 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 { FileText, Plus, Upload } from "lucide-react";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { 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 (
<div className="page-enter space-y-6">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Invoices"
description="Manage your invoices and track payments"
variant="gradient"
>
<Button asChild variant="outline" className="hover-lift shadow-sm">
<Link href="/dashboard/invoices/import">
@@ -28,25 +28,19 @@ export default async function InvoicesPage() {
<span>Import CSV</span>
</Link>
</Button>
<Button asChild variant="outline" className="hover-lift shadow-sm">
<Link href="/dashboard/invoices/new?blank=1">
<FileText className="mr-2 h-5 w-5" />
<span>Blank invoice</span>
</Link>
</Button>
<Button asChild variant="default" className="hover-lift shadow-md">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-5 w-5" />
<span>Create Invoice</span>
</Link>
</Button>
</PageHeader>
</DashboardPageHeader>
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
<InvoicesTable />
</Suspense>
</HydrateClient>
</div>
</DashboardPage>
);
}
+16 -13
View File
@@ -13,7 +13,9 @@ 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 { EmptyState } from "~/components/layout/page-layout";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
@@ -358,17 +360,16 @@ export default function RecurringInvoicesPage() {
const isSubmitting = create.isPending || update.isPending;
return (
<div className="page-enter space-y-6 pb-24">
<PageHeader
<DashboardPage className="pb-24">
<DashboardPageHeader
title="Recurring Invoices"
description="Schedule automatic invoice generation"
variant="gradient"
>
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
<Plus className="mr-2 h-4 w-4" />
New recurring
</Button>
</PageHeader>
</DashboardPageHeader>
{isLoading ? (
<div className="flex h-48 items-center justify-center">
@@ -376,16 +377,18 @@ export default function RecurringInvoicesPage() {
</div>
) : (recurring ?? []).length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<RefreshCw className="text-muted-foreground h-10 w-10" />
<p className="text-muted-foreground text-sm">
No recurring invoices yet. Create one to automatically generate draft invoices on a
schedule.
</p>
<CardContent className="p-0">
<EmptyState
icon={<RefreshCw className="h-6 w-6" />}
title="Create your first recurring invoice"
description="Automatically generate draft invoices on a schedule you choose."
action={
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
<Plus className="mr-2 h-4 w-4" />
Create first recurring invoice
Create recurring invoice
</Button>
}
/>
</CardContent>
</Card>
) : (
@@ -529,6 +532,6 @@ export default function RecurringInvoicesPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardPage>
);
}
+24 -18
View File
@@ -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 (
<div className="page-enter space-y-6 pb-6">
<PageHeader
<DashboardPage className="pb-6">
<DashboardPageHeader
title="Invoice Templates"
description="Reusable notes and payment terms for your invoices"
variant="gradient"
/>
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes">
<PageTabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
<PageTabsList>
<PageTabsTrigger value="notes">
<FileText className="mr-1.5 h-4 w-4" /> Notes (
{notesTemplates.length})
</TabsTrigger>
<TabsTrigger value="terms">
</PageTabsTrigger>
<PageTabsTrigger value="terms">
<FileText className="mr-1.5 h-4 w-4" /> Terms (
{termsTemplates.length})
</TabsTrigger>
</TabsList>
<TabsContent value="notes" className="mt-4">
</PageTabsTrigger>
</PageTabsList>
<PageTabsContent value="notes">
<TemplateList
items={notesTemplates}
type="notes"
@@ -214,8 +220,8 @@ export default function TemplatesPage() {
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent>
<TabsContent value="terms" className="mt-4">
</PageTabsContent>
<PageTabsContent value="terms">
<TemplateList
items={termsTemplates}
type="terms"
@@ -224,8 +230,8 @@ export default function TemplatesPage() {
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent>
</Tabs>
</PageTabsContent>
</PageTabs>
{/* Create/Edit dialog */}
<Dialog open={open} onOpenChange={setOpen}>
@@ -320,6 +326,6 @@ export default function TemplatesPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardPage>
);
}
+17
View File
@@ -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 (
<AppProviders>
<DashboardUserProvider isAdmin={isAdmin} needsOnboarding={needsOnboarding}>
<DashboardShell>{children}</DashboardShell>
</DashboardUserProvider>
</AppProviders>
);
}
@@ -0,0 +1,30 @@
import { Logo } from "~/components/branding/logo";
import { brand } from "~/lib/branding";
import { cn } from "~/lib/utils";
export function OnboardingShell({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className="bg-dashboard text-foreground flex min-h-screen flex-col px-5 py-8 sm:px-6 sm:py-10">
<div
className={cn(
"mx-auto flex w-full max-w-xl flex-1 flex-col justify-center",
className,
)}
>
<div className="mb-8 space-y-4 text-center">
<div className="flex justify-center">
<Logo size="lg" animated={false} />
</div>
<p className="text-muted-foreground text-sm leading-6">{brand.tagline}</p>
</div>
{children}
</div>
</div>
);
}
@@ -0,0 +1,92 @@
import { Check } from "lucide-react";
import { cn } from "~/lib/utils";
export const ONBOARDING_STEPS = [
{ id: "welcome", label: "Welcome" },
{ id: "business", label: "Business" },
{ id: "client", label: "Client" },
] as const;
export type OnboardingStepId = (typeof ONBOARDING_STEPS)[number]["id"] | "done";
function stepIndex(step: OnboardingStepId) {
if (step === "done") return ONBOARDING_STEPS.length;
return ONBOARDING_STEPS.findIndex((item) => item.id === step);
}
export function OnboardingStepIndicator({ step }: { step: OnboardingStepId }) {
const currentIndex = stepIndex(step);
return (
<nav aria-label="Setup progress" className="mb-8">
<ol className="mx-auto flex w-full max-w-md">
{ONBOARDING_STEPS.map((item, index) => {
const isComplete = currentIndex > index;
const isCurrent = currentIndex === index;
const isUpcoming = currentIndex < index;
const connectorComplete = currentIndex > index;
return (
<li key={item.id} className="flex flex-1 flex-col items-center">
<div className="flex w-full items-center">
{index > 0 && (
<div
className={cn(
"h-0.5 flex-1 rounded-full transition-colors",
connectorComplete || isCurrent
? "bg-primary"
: "bg-border/80",
)}
aria-hidden
/>
)}
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-2 text-sm font-medium transition-colors",
isComplete &&
"border-primary bg-primary text-primary-foreground",
isCurrent &&
"border-primary bg-primary/10 text-primary ring-primary/20 ring-4",
isUpcoming &&
"border-border/80 bg-background/60 text-muted-foreground",
)}
aria-current={isCurrent ? "step" : undefined}
>
{isComplete ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<span>{index + 1}</span>
)}
</div>
{index < ONBOARDING_STEPS.length - 1 && (
<div
className={cn(
"h-0.5 flex-1 rounded-full transition-colors",
connectorComplete ? "bg-primary" : "bg-border/80",
)}
aria-hidden
/>
)}
</div>
<span
className={cn(
"mt-2 hidden text-xs font-medium sm:block",
isCurrent ? "text-foreground" : "text-muted-foreground",
)}
>
{item.label}
</span>
</li>
);
})}
</ol>
<p className="text-muted-foreground mt-4 text-center text-sm sm:hidden">
Step {Math.min(currentIndex + 1, ONBOARDING_STEPS.length)} of{" "}
{ONBOARDING_STEPS.length}
{step !== "done" && ONBOARDING_STEPS[currentIndex]
? ` · ${ONBOARDING_STEPS[currentIndex].label}`
: ""}
</p>
</nav>
);
}
@@ -0,0 +1,365 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
ArrowRight,
Building2,
CheckCircle2,
FileText,
Users,
} from "lucide-react";
import { toast } from "sonner";
import { marketingSurfaceClass } from "~/components/marketing/marketing-chrome";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { brand } from "~/lib/branding";
import { cn } from "~/lib/utils";
import { api } from "~/trpc/react";
import {
OnboardingStepIndicator,
type OnboardingStepId,
} from "./onboarding-step-indicator";
type Step = OnboardingStepId;
function StepIcon({
icon: Icon,
className,
}: {
icon: React.ComponentType<{ className?: string }>;
className?: string;
}) {
return (
<div
className={cn(
"bg-primary/10 text-primary mb-5 inline-flex rounded-2xl p-3",
className,
)}
>
<Icon className="h-6 w-6" />
</div>
);
}
function OnboardingPanel({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
marketingSurfaceClass,
"bg-card/80 px-6 py-8 sm:px-8 sm:py-10",
className,
)}
>
{children}
</div>
);
}
export function OnboardingWizard() {
const router = useRouter();
const utils = api.useUtils();
const { data: status, isLoading } = api.settings.getOnboardingStatus.useQuery();
const [step, setStep] = useState<Step>("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]);
const displayStep = useMemo((): Step => {
if (step !== "welcome" || !status || status.completed) {
return step;
}
if (status.businessCount > 0 && status.clientCount > 0) {
return "done";
}
if (status.businessCount > 0) {
return "client";
}
return step;
}, [step, status]);
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 (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground text-sm">Loading</p>
</div>
);
}
return (
<div className="w-full">
{displayStep !== "done" && <OnboardingStepIndicator step={displayStep} />}
{displayStep === "welcome" && (
<OnboardingPanel>
<div className="text-center">
<p className="text-primary mb-3 text-sm font-medium tracking-wide uppercase">
Quick setup
</p>
<StepIcon icon={FileText} />
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
Welcome to {brand.name}
</h1>
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
Let&apos;s set up the basics so you can send your first invoice.
This only takes a minute.
</p>
</div>
<ul className="mt-8 space-y-4">
<li className="bg-background/50 border-border/50 flex items-start gap-3 rounded-xl border p-4">
<div className="bg-primary/10 text-primary shrink-0 rounded-lg p-2">
<Building2 className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium">Add your business</p>
<p className="text-muted-foreground mt-0.5 text-sm leading-6">
The name and details that appear on invoices you send.
</p>
</div>
</li>
<li className="bg-background/50 border-border/50 flex items-start gap-3 rounded-xl border p-4">
<div className="bg-primary/10 text-primary shrink-0 rounded-lg p-2">
<Users className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium">Add your first client</p>
<p className="text-muted-foreground mt-0.5 text-sm leading-6">
Who you&apos;re billing you can add more details later.
</p>
</div>
</li>
</ul>
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
<Button className="h-11 flex-1" size="lg" onClick={() => setStep("business")}>
Get started
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button
variant="ghost"
className="h-11"
onClick={handleSkip}
disabled={completeOnboarding.isPending}
>
Skip for now
</Button>
</div>
</OnboardingPanel>
)}
{displayStep === "business" && (
<OnboardingPanel>
<div className="text-center">
<StepIcon icon={Building2} />
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
Your business
</h1>
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
This appears on invoices as the sender name, logo, and contact
details.
</p>
</div>
<form onSubmit={handleBusinessSubmit} className="mt-8 space-y-5">
<div className="space-y-2">
<Label htmlFor="business-name">Business name</Label>
<Input
id="business-name"
value={businessName}
onChange={(e) => setBusinessName(e.target.value)}
placeholder="Acme Studio LLC"
className="h-11"
autoFocus
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="submit"
size="lg"
className="h-11 flex-1"
disabled={createBusiness.isPending}
>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
className="h-11"
onClick={handleSkip}
>
Skip for now
</Button>
</div>
</form>
</OnboardingPanel>
)}
{displayStep === "client" && (
<OnboardingPanel>
<div className="text-center">
<StepIcon icon={Users} />
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
Your first client
</h1>
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
Who are you billing? You can add more details later.
</p>
</div>
<form onSubmit={handleClientSubmit} className="mt-8 space-y-5">
<div className="space-y-2">
<Label htmlFor="client-name">Client name</Label>
<Input
id="client-name"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="Acme Corp"
className="h-11"
autoFocus
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="submit"
size="lg"
className="h-11 flex-1"
disabled={createClient.isPending}
>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
className="h-11"
onClick={handleSkip}
>
Skip for now
</Button>
</div>
</form>
</OnboardingPanel>
)}
{displayStep === "done" && (
<OnboardingPanel className="text-center">
<div className="bg-primary/10 text-primary mx-auto mb-5 inline-flex rounded-full p-3">
<CheckCircle2 className="h-7 w-7" />
</div>
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
You&apos;re ready to go
</h1>
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
Your workspace is set up. Create an invoice or explore the dashboard.
</p>
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
<Button size="lg" className="h-11 flex-1" onClick={handleFinish}>
Go to dashboard
</Button>
<Button
variant="outline"
size="lg"
className="h-11 flex-1"
onClick={handleCreateInvoice}
>
Create first invoice
</Button>
</div>
</OnboardingPanel>
)}
{step !== "welcome" && displayStep !== "done" && (
<div className="mt-6 text-center">
<Button
variant="link"
className="text-muted-foreground"
onClick={() =>
setStep(displayStep === "client" ? "business" : "welcome")
}
>
Back
</Button>
</div>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { OnboardingShell } from "./_components/onboarding-shell";
import { OnboardingWizard } from "./_components/onboarding-wizard";
export default function OnboardingPage() {
return (
<OnboardingShell>
<OnboardingWizard />
</OnboardingShell>
);
}
+166 -284
View File
@@ -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 (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className={cn(dashboardGridClass, "sm:grid-cols-2 xl:grid-cols-4")}>
{statCards.map((stat, index) => (
<AnimatedStatsCard
key={stat.title}
@@ -98,21 +108,15 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
);
}
// Charts section
async function ChartsSection({ stats }: { stats: DashboardStats }) {
// We still fetch all invoices for the status chart for now, or we could aggregate that too.
// For now, let's keep status chart as is (fetching all) but use aggregated for revenue.
// Actually, let's fetch invoices here for the status chart to keep it working.
const invoices = await api.invoices.getAll();
function ChartsSection({ stats }: { stats: DashboardStats }) {
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Revenue Trend Chart */}
<DashboardGrid className="lg:grid-cols-2">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Revenue Over Time
<CardTitle>
<DashboardCardTitle icon={BarChart3}>
Revenue over time
</DashboardCardTitle>
</CardTitle>
</CardHeader>
<CardContent>
@@ -120,55 +124,54 @@ async function ChartsSection({ stats }: { stats: DashboardStats }) {
</CardContent>
</Card>
{/* Invoice Status Breakdown */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Invoice Status
<CardTitle>
<DashboardCardTitle icon={Activity}>
Invoice status
</DashboardCardTitle>
</CardTitle>
</CardHeader>
<CardContent>
<InvoiceStatusChart invoices={invoices} />
<InvoiceStatusChart data={stats.statusChartData} />
</CardContent>
</Card>
{/* Monthly Metrics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Monthly Metrics
<CardTitle>
<DashboardCardTitle icon={Calendar}>
Monthly metrics
</DashboardCardTitle>
</CardTitle>
</CardHeader>
<CardContent>
<MonthlyMetricsChart invoices={invoices} />
<MonthlyMetricsChart data={stats.monthlyMetricsChartData} />
</CardContent>
</Card>
</div>
</DashboardGrid>
);
}
// 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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
Quick Actions
<CardTitle>
<DashboardCardTitle icon={Plus}>Quick actions</DashboardCardTitle>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-2">
{actions.map((action) => {
const Icon = action.icon;
return (
<Link
key={action.title}
href={action.href}
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${
className={cn(
"flex items-start gap-3 rounded-2xl border p-4 transition-colors",
action.featured
? "border-foreground/20 bg-muted/50 hover:bg-muted"
: "border-border bg-background hover:bg-muted/50"
}`}
? "border-primary/20 bg-primary/5 hover:bg-primary/10"
: "border-border/60 bg-background/50 hover:bg-muted/50",
)}
>
<Icon className="h-5 w-5 flex-shrink-0" />
<div
className={cn(
"rounded-xl p-2",
action.featured
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold">{action.title}</p>
<p className="text-sm font-medium">{action.title}</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{action.description}
</p>
@@ -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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Current Work
<CardTitle>
<DashboardCardTitle icon={Activity}>
Current work
</DashboardCardTitle>
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-8 text-center">
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">No active drafts</h3>
<p className="text-muted-foreground mb-4">
Create a new invoice to get started
</p>
<Button asChild variant="outline" className="border-foreground/20">
<CardContent className="flex flex-col items-center py-8 text-center">
<div className="bg-muted mb-4 rounded-2xl p-3">
<FileText className="text-muted-foreground h-6 w-6" />
</div>
<p className="font-medium">No draft in progress</p>
<CardDescription className="mt-1 max-w-xs">
Start an invoice when you&apos;re ready to bill your next piece of
work.
</CardDescription>
<Button asChild variant="outline" className="mt-5">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create Invoice
Create invoice
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
const totalHours =
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
const totalHours = currentDraft.totalHours;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Current Work
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>
<DashboardCardTitle icon={Activity}>Current work</DashboardCardTitle>
</CardTitle>
<Badge variant="secondary">In Progress</Badge>
<Badge variant="secondary">Draft</Badge>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-lg font-semibold break-words">
#{currentInvoice.invoiceNumber}
</h3>
<span className="text-primary text-2xl font-bold">
${currentInvoice.totalAmount.toFixed(2)}
</span>
<CardContent className="space-y-5">
<div className="space-y-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="font-medium">#{currentDraft.invoiceNumber}</p>
<p className="text-muted-foreground text-sm">
{currentDraft.client?.name}
</p>
</div>
<div className="text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between">
<span className="break-words">{currentInvoice.client?.name}</span>
<span className="text-xs sm:text-sm">
<p className="font-mono text-xl font-semibold tabular-nums">
${currentDraft.totalAmount.toFixed(2)}
</p>
</div>
<p className="text-muted-foreground font-mono text-xs tabular-nums">
{totalHours.toFixed(1)} hours logged
</span>
</div>
</p>
</div>
<div className="flex gap-2">
<Button
asChild
variant="outline"
size="sm"
className="hover-lift flex-1"
>
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
<Button asChild variant="outline" size="sm" className="flex-1">
<Link href={`/dashboard/invoices/${currentDraft.id}`}>
<Eye className="mr-2 h-4 w-4" />
View
</Link>
</Button>
<Button asChild size="sm" className="hover-lift flex-1">
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
<Button asChild size="sm" className="flex-1">
<Link href={`/dashboard/invoices/${currentDraft.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Continue
</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
// 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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Recent Activity
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>
<DashboardCardTitle icon={Calendar}>
Recent activity
</DashboardCardTitle>
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href="/dashboard/invoices">
<span className="hidden sm:inline">View All</span>
<span className="hidden sm:inline">View all</span>
<ArrowUpRight className="h-4 w-4 sm:ml-1" />
</Link>
</Button>
</CardHeader>
<CardContent>
{recentInvoices.length === 0 ? (
<div className="py-8 text-center">
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">No invoices yet</h3>
<p className="text-muted-foreground mb-4">
Create your first invoice to get started
</p>
<Button asChild variant="outline" className="border-foreground/20">
<div className="flex flex-col items-center py-8 text-center">
<div className="bg-muted mb-4 rounded-2xl p-3">
<FileText className="text-muted-foreground h-6 w-6" />
</div>
<p className="font-medium">No invoices yet</p>
<CardDescription className="mt-1 max-w-xs">
Your latest invoices will show up here.
</CardDescription>
<Button asChild variant="outline" className="mt-5">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create Your First Invoice
Create invoice
</Link>
</Button>
</div>
) : (
<div className="space-y-3">
{recentInvoices.map((invoice, _index) => (
<div className="space-y-2">
{recentInvoices.map((invoice) => (
<Link
key={invoice.id}
href={`/dashboard/invoices/${invoice.id}`}
className="block"
className="hover:bg-muted/50 border-border/60 flex items-center gap-3 rounded-2xl border p-3 transition-colors"
>
<div className="recent-activity-item bg-muted/50 hover:bg-muted border-foreground/20 rounded-lg border p-3 transition-colors">
<div className="flex items-start gap-3">
<div className="bg-muted flex-shrink-0 rounded-lg p-2">
<div className="bg-muted rounded-xl p-2">
<FileText className="text-muted-foreground h-4 w-4" />
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="truncate font-medium">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-medium">
#{invoice.invoiceNumber}
</p>
<p className="text-muted-foreground truncate text-sm">
{invoice.client?.name}
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<Badge style={getStatusStyle(invoice.status)}>
{invoice.status}
</Badge>
<span className="text-primary font-semibold">
<span className="shrink-0 font-mono text-sm font-medium tabular-nums">
${invoice.totalAmount.toFixed(2)}
</span>
</div>
</div>
<p className="text-muted-foreground text-xs">
{new Date(invoice.issueDate).toLocaleDateString()}
<div className="mt-1 flex items-center justify-between gap-2">
<p className="text-muted-foreground truncate text-xs">
{invoice.client?.name}
</p>
</div>
<Badge
variant={getStatusVariant(invoice.status)}
className="shrink-0 text-[10px]"
>
{invoice.status}
</Badge>
</div>
</div>
</Link>
@@ -420,121 +396,27 @@ async function RecentActivity({
);
}
// Loading skeletons
function StatsSkeleton() {
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-12" />
</div>
<Skeleton className="mb-2 h-8 w-20" />
<Skeleton className="h-3 w-32" />
</CardContent>
</Card>
))}
</div>
);
}
function ChartsSkeleton() {
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card className="lg:col-span-2">
<CardHeader>
<Skeleton className="h-6 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-36" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
</div>
);
}
function CardSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</CardContent>
</Card>
);
}
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 (
<div className="page-enter space-y-6">
<DashboardPageLayout>
<DashboardPageHeader
title={`Welcome back, ${firstName}!`}
description="Here's what's happening with your business today"
title={`Welcome back, ${firstName}`}
description="A snapshot of your invoices, revenue, and work in progress."
/>
<HydrateClient>
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats stats={stats} />
</Suspense>
</HydrateClient>
<HydrateClient>
<Suspense fallback={<ChartsSkeleton />}>
<ChartsSection stats={stats} />
</Suspense>
</HydrateClient>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="space-y-6">
<HydrateClient>
<Suspense fallback={<CardSkeleton />}>
<CurrentWork />
</Suspense>
</HydrateClient>
<DashboardGrid className="lg:grid-cols-2">
<div className={cn(dashboardGridClass)}>
<CurrentWork currentDraft={stats.currentDraft} />
<QuickActions />
</div>
<HydrateClient>
<Suspense fallback={<CardSkeleton />}>
<RecentActivity recentInvoices={stats.recentInvoices} />
</Suspense>
</HydrateClient>
</div>
</div>
</DashboardGrid>
</DashboardPageLayout>
);
}
+30 -26
View File
@@ -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 (
<div className="page-enter space-y-6">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Reports"
description="Revenue and tax analytics"
variant="gradient"
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className={dashboardStatGridClass}>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
))}
</div>
</div>
</DashboardPage>
);
}
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Reports"
description="Revenue and tax analytics"
variant="gradient"
/>
<Tabs defaultValue="overview">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="overview">
<TrendingUp className="mr-1.5 h-4 w-4" /> Overview
</TabsTrigger>
<TabsTrigger value="tax">
<FileText className="mr-1.5 h-4 w-4" /> Tax Summary
</TabsTrigger>
</TabsList>
<PageTabs defaultValue="overview">
<PageTabsList>
<PageTabsTrigger value="overview" className="gap-1.5">
<TrendingUp className="h-4 w-4" /> Overview
</PageTabsTrigger>
<PageTabsTrigger value="tax" className="gap-1.5">
<FileText className="h-4 w-4" /> Tax Summary
</PageTabsTrigger>
</PageTabsList>
{/* ── OVERVIEW TAB ── */}
<TabsContent value="overview" className="mt-4 space-y-6">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<PageTabsContent value="overview">
<div className={dashboardStatGridClass}>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
@@ -630,10 +634,10 @@ export default function ReportsPage() {
</CardContent>
</Card>
)}
</TabsContent>
</PageTabsContent>
{/* ── TAX SUMMARY TAB ── */}
<TabsContent value="tax" className="mt-4 space-y-6">
<PageTabsContent value="tax">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Tax Year</span>
@@ -840,8 +844,8 @@ export default function ReportsPage() {
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</PageTabsContent>
</PageTabs>
</DashboardPage>
);
}
@@ -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 (
<Tabs defaultValue="general">
<TabsList className="bg-muted/50 grid w-full grid-cols-4">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="preferences">Preferences</TabsTrigger>
<TabsTrigger value="data">Data</TabsTrigger>
<TabsTrigger value="api">API</TabsTrigger>
</TabsList>
<PageTabs defaultValue="general">
<PageTabsList>
<PageTabsTrigger value="general">General</PageTabsTrigger>
<PageTabsTrigger value="preferences">Preferences</PageTabsTrigger>
<PageTabsTrigger value="data">Data</PageTabsTrigger>
<PageTabsTrigger value="api">API</PageTabsTrigger>
</PageTabsList>
<TabsContent value="general" className="space-y-8">
<div className="grid gap-6 lg:grid-cols-2">
<PageTabsContent value="general">
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
{/* Profile Section */}
<Card className="form-section bg-card border-border border">
<CardHeader>
@@ -724,9 +636,9 @@ export function SettingsContent() {
</Button>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
<TabsContent value="preferences" className="space-y-8">
<PageTabsContent value="preferences">
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
@@ -734,152 +646,14 @@ export function SettingsContent() {
Appearance
</CardTitle>
<CardDescription>
Select the app skin, color mode, accent, and font stack.
Choose light, dark, or match your system setting.
</CardDescription>
</CardHeader>
{!isAdmin ? (
<CardContent>
<p className="text-muted-foreground text-sm">
Platform appearance and branding are managed by an
administrator.
</p>
</CardContent>
) : (
<CardContent className="space-y-8">
<section className="space-y-4">
<div>
<h3 className="text-sm font-medium">Brand</h3>
<p className="text-muted-foreground text-xs">
Public-facing name, logo text, and short product tagline.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Brand Name</Label>
<Input
value={brandName}
onChange={(event) =>
updateAppearanceDebounced({
brandName: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label>Logo Text</Label>
<Input
value={brandLogoText}
onChange={(event) =>
updateAppearanceDebounced({
brandLogoText: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label>Brand Icon</Label>
<Input
value={brandIcon}
onChange={(event) =>
updateAppearanceDebounced({
brandIcon: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label>Tagline</Label>
<Input
value={brandTagline}
onChange={(event) =>
updateAppearanceDebounced({
brandTagline: event.target.value,
})
}
/>
</div>
</div>
</section>
<section className="space-y-4 border-t pt-6">
<div>
<h3 className="text-sm font-medium">Theme</h3>
<p className="text-muted-foreground text-xs">
Presets establish the broad visual language; color mode and
accent can still be tuned independently.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label className="flex items-center gap-2">
<Paintbrush className="h-4 w-4" />
Theme Preset
</Label>
<div className="flex items-center gap-2">
{themeModified && (
<Badge variant="secondary" className="shrink-0">
modified
</Badge>
)}
{themeModified && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => updateAppearance(activePreset)}
>
Reset
</Button>
)}
</div>
</div>
<Select
value={interfaceTheme}
onValueChange={(value) => {
const nextTheme = value as InterfaceTheme;
updateAppearance(themePresets[nextTheme]);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{interfaceThemes.map((themeOption) => (
<SelectItem
key={themeOption.value}
value={themeOption.value}
>
{themeOption.label}
{themeOption.value === interfaceTheme &&
themeModified
? " (modified)"
: ""}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
Applies the theme, fonts, accent, corner radius,
navigation chrome, and PDF defaults.
</p>
<p className="text-muted-foreground text-xs leading-snug">
{
interfaceThemes.find(
(themeOption) => themeOption.value === interfaceTheme,
)?.description
}
</p>
</div>
<div className="space-y-2">
<CardContent className="space-y-4">
<div className="max-w-sm space-y-2">
<Label className="flex items-center gap-2">
<Monitor className="h-4 w-4" />
Color Mode
Color mode
</Label>
<Select
value={colorMode}
@@ -911,271 +685,10 @@ export function SettingsContent() {
}
</p>
</div>
</div>
</section>
<section className="space-y-4 border-t pt-6">
<div>
<h3 className="text-sm font-medium">Typography</h3>
<p className="text-muted-foreground text-xs">
Body and heading fonts are separate so white-label installs
can feel native without losing hierarchy.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Type className="h-4 w-4" />
Body Font
</Label>
<Select
value={bodyFontPreference}
onValueChange={(value) =>
updateAppearance({
bodyFontPreference:
value as typeof bodyFontPreference,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{bodyFontPreferences.map((fontOption) => (
<SelectItem
key={fontOption.value}
value={fontOption.value}
>
{fontOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
{
bodyFontPreferences.find(
(fontOption) =>
fontOption.value === bodyFontPreference,
)?.description
}
</p>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Type className="h-4 w-4" />
Heading Font
</Label>
<Select
value={headingFontPreference}
onValueChange={(value) =>
updateAppearance({
headingFontPreference:
value as typeof headingFontPreference,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{headingFontPreferences.map((fontOption) => (
<SelectItem
key={fontOption.value}
value={fontOption.value}
>
{fontOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
{
headingFontPreferences.find(
(fontOption) =>
fontOption.value === headingFontPreference,
)?.description
}
</p>
</div>
</div>
</section>
<section className="space-y-4 border-t pt-6">
<div>
<h3 className="text-sm font-medium">Color</h3>
<p className="text-muted-foreground text-xs">
Accent controls primary actions, focus rings, and branded
highlights.
</p>
</div>
<div className="space-y-3">
<Label>Accent</Label>
<div className="grid gap-2 sm:grid-cols-3">
{colorThemes.map((themeOption) => (
<button
key={themeOption.value}
type="button"
onClick={() => selectAccent(themeOption.value)}
className={`border-border bg-background hover:bg-muted flex items-center gap-2 rounded-lg border p-2 text-left text-sm transition-colors ${
colorTheme === themeOption.value
? "border-primary bg-muted text-foreground"
: ""
}`}
>
<span
className="size-4 rounded-full border"
style={{ backgroundColor: themeOption.swatch }}
/>
{themeOption.label}
</button>
))}
<button
type="button"
onClick={() => selectAccent("custom")}
className={`border-border bg-background hover:bg-muted flex items-center gap-2 rounded-lg border p-2 text-left text-sm transition-colors ${
colorTheme === "custom"
? "border-primary bg-muted text-foreground"
: ""
}`}
>
<span
className="size-4 rounded-full border"
style={{
backgroundColor: customColor
? `hsl(${customColor})`
: "hsl(142.1 76.2% 36.3%)",
}}
/>
Custom
</button>
</div>
{colorTheme === "custom" && (
<div className="space-y-2">
<InputColor
label="Custom Accent"
value={hslChannelsToHex(customColorValue)}
onBlur={() => undefined}
onChange={(value) => {
if (isFullHexColor(value)) {
updateAppearanceDebounced({
colorTheme: "custom",
customColor: hexToHslChannels(value),
});
}
}}
className="mt-0"
/>
<Input
value={customColorValue}
onChange={(event) =>
updateAppearanceDebounced({
colorTheme: "custom",
customColor: event.target.value,
})
}
placeholder="142.1 76.2% 36.3%"
/>
</div>
)}
<p className="text-muted-foreground text-xs leading-snug">
Custom values use HSL channels, for example 142.1 76.2%
36.3%.
</p>
</div>
</section>
<section className="space-y-4 border-t pt-6">
<div>
<h3 className="text-sm font-medium">Layout</h3>
<p className="text-muted-foreground text-xs">
Control global rounding and whether navigation floats or
sits flush with the viewport.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Paintbrush className="h-4 w-4" />
Corner Radius
</Label>
<Select
value={radiusPreference}
onValueChange={(value) =>
updateAppearance({
radiusPreference: value as typeof radiusPreference,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{radiusPreferences.map((radiusOption) => (
<SelectItem
key={radiusOption.value}
value={radiusOption.value}
>
{radiusOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
{
radiusPreferences.find(
(radiusOption) =>
radiusOption.value === radiusPreference,
)?.description
}
</p>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
<PanelLeft className="h-4 w-4" />
Navigation Chrome
</Label>
<Select
value={sidebarStyle}
onValueChange={(value) =>
updateAppearance({
sidebarStyle: value as typeof sidebarStyle,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{sidebarStyles.map((styleOption) => (
<SelectItem
key={styleOption.value}
value={styleOption.value}
>
{styleOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
{
sidebarStyles.find(
(styleOption) => styleOption.value === sidebarStyle,
)?.description
}
</p>
</div>
</div>
</section>
{appearanceUpdating && (
<p className="text-muted-foreground text-xs">
Saving appearance...
</p>
<p className="text-muted-foreground text-xs">Saving...</p>
)}
</CardContent>
)}
</Card>
{isAdmin && (
@@ -1191,7 +704,12 @@ export function SettingsContent() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
<div
className={cn(
pageTabsGridClass,
"xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]",
)}
>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
<div className="space-y-2">
@@ -1200,12 +718,13 @@ export function SettingsContent() {
PDF Template
</Label>
<Select
value={pdfTemplate}
value={pdfSettings?.pdfTemplate ?? "classic"}
onValueChange={(value) =>
updateAppearance({
pdfTemplate: value as typeof pdfTemplate,
savePdfSettings({
pdfTemplate: value as PdfTemplate,
})
}
disabled={updatePdfSettingsMutation.isPending}
>
<SelectTrigger>
<SelectValue />
@@ -1224,13 +743,11 @@ export function SettingsContent() {
<div className="space-y-2">
<InputColor
label="PDF Accent"
value={pdfAccentColor}
value={pdfSettings?.pdfAccentColor ?? "#111827"}
onBlur={() => undefined}
onChange={(value) => {
if (isFullHexColor(value)) {
updateAppearance({
pdfAccentColor: value,
});
savePdfSettings({ pdfAccentColor: value });
}
}}
className="mt-0"
@@ -1241,12 +758,11 @@ export function SettingsContent() {
<div className="space-y-2">
<Label>Footer Text</Label>
<Input
value={pdfFooterText}
value={pdfSettings?.pdfFooterText ?? ""}
onChange={(event) =>
updateAppearanceDebounced({
pdfFooterText: event.target.value,
})
savePdfSettings({ pdfFooterText: event.target.value })
}
disabled={updatePdfSettingsMutation.isPending}
/>
</div>
@@ -1259,10 +775,11 @@ export function SettingsContent() {
</p>
</div>
<Switch
checked={pdfShowLogo}
checked={pdfSettings?.pdfShowLogo ?? true}
onCheckedChange={(checked) =>
updateAppearance({ pdfShowLogo: Boolean(checked) })
savePdfSettings({ pdfShowLogo: Boolean(checked) })
}
disabled={updatePdfSettingsMutation.isPending}
aria-label="Toggle PDF logo"
/>
</div>
@@ -1275,12 +792,13 @@ export function SettingsContent() {
</p>
</div>
<Switch
checked={pdfShowPageNumbers}
checked={pdfSettings?.pdfShowPageNumbers ?? true}
onCheckedChange={(checked) =>
updateAppearance({
savePdfSettings({
pdfShowPageNumbers: Boolean(checked),
})
}
disabled={updatePdfSettingsMutation.isPending}
aria-label="Toggle PDF page numbers"
/>
</div>
@@ -1288,13 +806,14 @@ export function SettingsContent() {
</div>
<PdfPreviewFrame
businessName={brandName}
businessName={brand.name}
settings={{
pdfTemplate,
pdfAccentColor,
pdfFooterText,
pdfShowLogo,
pdfShowPageNumbers,
pdfTemplate: pdfSettings?.pdfTemplate ?? "classic",
pdfAccentColor: pdfSettings?.pdfAccentColor ?? "#111827",
pdfFooterText:
pdfSettings?.pdfFooterText ?? "Professional Invoicing",
pdfShowLogo: pdfSettings?.pdfShowLogo ?? true,
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers ?? true,
}}
/>
</div>
@@ -1397,9 +916,9 @@ export function SettingsContent() {
</form>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
<TabsContent value="data" className="space-y-8">
<PageTabsContent value="data">
{/* Data Overview */}
<Card className="form-section bg-card border-border border">
<CardHeader>
@@ -1621,7 +1140,7 @@ export function SettingsContent() {
</Card>
{/* Delete Account (Danger Zone) */}
<Card className="border-destructive/50 bg-destructive/5 border">
<Card className="bg-card border-destructive/50 border">
<CardHeader>
<CardTitle className="text-destructive flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
@@ -1672,11 +1191,11 @@ export function SettingsContent() {
</AlertDialog>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
<TabsContent value="api" className="space-y-8">
<PageTabsContent value="api">
<ApiAccessSettings />
</TabsContent>
</Tabs>
</PageTabsContent>
</PageTabs>
);
}
+5 -5
View File
@@ -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 (
<div className="page-enter space-y-6">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Settings"
description="Manage your account preferences and data"
variant="gradient"
/>
<HydrateClient>
@@ -18,6 +18,6 @@ export default async function SettingsPage() {
<SettingsContent />
</Suspense>
</HydrateClient>
</div>
</DashboardPage>
);
}
+3 -2
View File
@@ -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 (
<div className="page-enter space-y-6">
<DashboardPage>
<DashboardPageHeader
title="Time clock"
description="Track billable hours and save them directly to an invoice"
@@ -28,6 +29,6 @@ export default async function TimeClockPage({
defaultInvoiceId={params.invoiceId}
/>
</HydrateClient>
</div>
</DashboardPage>
);
}
+25 -52
View File
@@ -4,21 +4,33 @@ 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 { getAppUrl } from "~/lib/app-url";
import { brand } from "~/lib/branding";
import { UmamiScript } from "~/components/analytics/umami-script";
import { BrandBackground } from "~/components/layout/brand-background";
const siteTitle = `${brand.name} - Invoicing Made Simple`;
export const metadata: Metadata = {
title: `${brand.name} - Invoicing Made Simple`,
metadataBase: new URL(getAppUrl()),
title: {
default: siteTitle,
template: `%s | ${brand.name}`,
},
description: brand.tagline,
openGraph: {
title: siteTitle,
description: brand.tagline,
siteName: brand.name,
type: "website",
locale: "en_US",
},
twitter: {
card: "summary_large_image",
title: siteTitle,
description: brand.tagline,
},
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
@@ -34,23 +46,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 +59,8 @@ export default function RootLayout({
<html
suppressHydrationWarning
lang="en"
data-interface-theme={defaultInterfaceTheme}
data-body-font={defaultBodyFontPreference}
data-heading-font={defaultHeadingFontPreference}
data-radius={defaultRadiusPreference}
data-sidebar-style={defaultSidebarStyle}
data-color-mode="system"
data-color-theme="slate"
className={`${geistSans.variable} ${playfair.variable} ${frutiger.variable} ${geistMono.variable}`}
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
>
<head>
<script
@@ -79,27 +68,11 @@ export default function RootLayout({
dangerouslySetInnerHTML={{
__html: `
try {
var defaults = {
interfaceTheme: "${defaultInterfaceTheme}",
bodyFontPreference: "${defaultBodyFontPreference}",
headingFontPreference: "${defaultHeadingFontPreference}",
radiusPreference: "${defaultRadiusPreference}",
sidebarStyle: "${defaultSidebarStyle}",
colorMode: "system",
colorTheme: "slate"
};
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
var appearance = Object.assign(defaults, stored);
var colorMode = stored.colorMode || "system";
var root = document.documentElement;
root.dataset.interfaceTheme = appearance.interfaceTheme;
root.dataset.bodyFont = appearance.bodyFontPreference;
root.dataset.headingFont = appearance.headingFontPreference;
root.dataset.radius = appearance.radiusPreference;
root.dataset.sidebarStyle = appearance.sidebarStyle;
root.dataset.colorMode = appearance.colorMode;
root.dataset.colorTheme = appearance.colorTheme;
if (appearance.colorMode === "dark") root.classList.add("dark");
if (appearance.customColor) root.style.setProperty("--custom-primary", appearance.customColor);
root.dataset.colorMode = colorMode;
if (colorMode === "dark") root.classList.add("dark");
} catch {}
`,
}}
+132
View File
@@ -0,0 +1,132 @@
import { ImageResponse } from "next/og";
import { brand, splitLogoText } from "~/lib/branding";
export const alt = `${brand.name} - Invoicing Made Simple`;
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image() {
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
const geistMono = await fetch(
new URL(
"../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
import.meta.url,
),
).then((res) => res.arrayBuffer());
const playfair = await fetch(
new URL(
"../../node_modules/@fontsource-variable/playfair-display/files/playfair-display-latin-wght-normal.woff2",
import.meta.url,
),
).then((res) => res.arrayBuffer());
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#ffffff",
position: "relative",
}}
>
<div
style={{
position: "absolute",
inset: 0,
backgroundImage:
"linear-gradient(to right, rgba(128,128,128,0.07) 1px, transparent 1px), linear-gradient(to bottom, rgba(128,128,128,0.07) 1px, transparent 1px)",
backgroundSize: "24px 24px",
}}
/>
<div
style={{
position: "absolute",
width: 520,
height: 520,
borderRadius: "50%",
backgroundColor: "rgba(163, 163, 163, 0.25)",
filter: "blur(80px)",
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 1,
padding: "0 80px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
fontFamily: "Geist Mono",
fontSize: 72,
fontWeight: 700,
letterSpacing: "-0.02em",
}}
>
<span style={{ color: "#18181b" }}>{brand.icon}</span>
<span style={{ width: 16 }} />
<span style={{ color: "#09090b" }}>{logoPrefix}</span>
<span style={{ color: "rgba(9, 9, 11, 0.7)" }}>{logoSuffix}</span>
</div>
<div
style={{
marginTop: 32,
fontFamily: "Playfair Display",
fontSize: 40,
fontWeight: 600,
color: "#09090b",
textAlign: "center",
letterSpacing: "-0.02em",
}}
>
Invoicing Made Simple
</div>
<div
style={{
marginTop: 16,
fontFamily: "Geist Mono",
fontSize: 22,
fontWeight: 400,
color: "#71717a",
textAlign: "center",
maxWidth: 900,
lineHeight: 1.4,
}}
>
{brand.tagline}
</div>
</div>
</div>
),
{
...size,
fonts: [
{
name: "Geist Mono",
data: geistMono,
style: "normal",
weight: 700,
},
{
name: "Playfair Display",
data: playfair,
style: "normal",
weight: 600,
},
],
},
);
}
+7 -24
View File
@@ -1,8 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { brand } from "~/lib/branding";
import { useAppearance } from "~/components/providers/appearance-provider";
import { brand, splitLogoText } from "~/lib/branding";
import { cn } from "~/lib/utils";
interface LogoProps {
@@ -11,24 +10,8 @@ interface LogoProps {
animated?: boolean;
}
function splitLogoText(logoText: string) {
const voiceIndex = logoText.toLowerCase().indexOf("voice");
if (voiceIndex > 0) {
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
}
return [
logoText.slice(0, Math.ceil(logoText.length / 2)),
logoText.slice(Math.ceil(logoText.length / 2)),
] as const;
}
export function Logo({ className, size = "md", animated = true }: LogoProps) {
const appearance = useAppearance();
const logoText = appearance.brandLogoText || brand.logoText;
const icon = appearance.brandIcon || brand.icon;
const [logoPrefix, logoSuffix] = splitLogoText(logoText);
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
const sizeClasses = {
sm: "text-base",
md: "text-xl",
@@ -45,7 +28,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
sizeClasses={sizeClasses}
logoPrefix={logoPrefix}
logoSuffix={logoSuffix}
icon={icon}
icon={brand.icon}
/>
);
}
@@ -67,7 +50,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
className="text-primary font-bold tracking-tight"
>
{icon}
{brand.icon}
</motion.span>
{size !== "icon" && (
<>
@@ -75,8 +58,8 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }}
className="inline-block w-1" // Reduced from w-2 to w-1 (half space)
></motion.span>
className="inline-block w-1"
/>
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -125,7 +108,7 @@ function LogoContent({
<span className="text-primary font-bold tracking-tight">{icon}</span>
{size !== "icon" && (
<>
<span className="inline-block w-1"></span>
<span className="inline-block w-1" />
<span className="text-foreground font-bold tracking-tight">
{logoPrefix}
</span>
@@ -0,0 +1,25 @@
"use client";
import type { ReactElement } from "react";
import { ResponsiveContainer } from "recharts";
import { cn } from "~/lib/utils";
interface ResponsiveChartProps {
height?: number;
className?: string;
children: ReactElement;
}
export function ResponsiveChart({
height = 256,
className,
children,
}: ResponsiveChartProps) {
return (
<div className={cn("w-full min-w-0", className)}>
<ResponsiveContainer width="100%" height={height} minWidth={0}>
{children}
</ResponsiveContainer>
</div>
);
}
+66 -6
View File
@@ -24,10 +24,12 @@ import {
ChevronsRight,
Filter,
Search,
SearchX,
X,
} from "lucide-react";
import * as React from "react";
import { EmptyState } from "~/components/layout/page-layout";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import {
@@ -87,6 +89,41 @@ interface DataTableProps<TData, TValue> {
clearSelection: () => void,
) => React.ReactNode;
initialSorting?: SortingState;
/** Shown when the dataset is empty (no rows in DB). */
emptyTitle?: string;
emptyDescription?: string;
emptyIcon?: React.ReactNode;
emptyAction?: React.ReactNode;
/** Shown when filters/search hide all rows but data exists. */
filteredEmptyTitle?: string;
filteredEmptyDescription?: string;
}
export interface DataTableEmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
/** Centered empty state for data tables (reuses page EmptyState). */
export function DataTableEmptyState({
icon,
title,
description,
action,
className,
}: DataTableEmptyStateProps) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
className={cn("py-16", className)}
/>
);
}
export function DataTable<TData, TValue>({
@@ -106,6 +143,12 @@ export function DataTable<TData, TValue>({
onRowClick,
selectionActions,
initialSorting = [],
emptyTitle,
emptyDescription,
emptyIcon,
emptyAction,
filteredEmptyTitle = "No matches for your search",
filteredEmptyDescription = "Try adjusting your search or filters.",
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@@ -190,6 +233,9 @@ export function DataTable<TData, TValue>({
}, [globalFilter]);
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
const filteredRowCount = table.getFilteredRowModel().rows.length;
const isDatasetEmpty = data.length === 0;
const isFilteredEmpty = !isDatasetEmpty && filteredRowCount === 0;
// Handle row click
const handleRowClick = (row: TData, event: React.MouseEvent) => {
@@ -419,12 +465,26 @@ export function DataTable<TData, TValue>({
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
<p className="text-muted-foreground">No results found</p>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="p-0">
{isDatasetEmpty && emptyTitle ? (
<DataTableEmptyState
icon={emptyIcon}
title={emptyTitle}
description={emptyDescription}
action={emptyAction}
/>
) : isFilteredEmpty ? (
<DataTableEmptyState
icon={<SearchX className="h-6 w-6" />}
title={filteredEmptyTitle}
description={filteredEmptyDescription}
/>
) : (
<div className="text-muted-foreground py-16 text-center text-sm">
No results found
</div>
)}
</TableCell>
</TableRow>
)}
+11
View File
@@ -155,11 +155,22 @@ export function InvoiceList() {
<Eye className="h-4 w-4" />
</Button>
</Link>
{invoice.status === "draft" ? (
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
) : (
<Button
variant="ghost"
size="sm"
disabled
title="Only draft invoices can be edited"
>
<Edit className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
+14 -9
View File
@@ -20,7 +20,9 @@ import { useEffect, useState } from "react";
import { toast } from "sonner";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
@@ -408,7 +410,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
(mode === "edit" && isLoadingEmailConfig)
) {
return (
<div className="space-y-6 pb-32">
<DashboardPage className="pb-32">
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
@@ -430,21 +432,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</div>
</CardContent>
</Card>
</div>
</DashboardPage>
);
}
return (
<>
<div className="space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title={mode === "edit" ? "Edit Business" : "Add Business"}
description={
mode === "edit"
? "Update business information below"
: "Enter business details below to add a new business."
}
variant="gradient"
>
<Button
type="submit"
@@ -469,9 +470,13 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</>
)}
</Button>
</PageHeader>
</DashboardPageHeader>
<form id="business-form" onSubmit={handleSubmit} className="space-y-6">
<form
id="business-form"
onSubmit={handleSubmit}
className={cn("flex flex-col", dashboardGapClass)}
>
{/* Main Form Container - styled like data table */}
<div className="space-y-4">
{/* Basic Information */}
@@ -902,7 +907,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</Card>
</div>
</form>
</div>
</DashboardPage>
<FloatingActionBar
leftContent={
+14 -9
View File
@@ -19,7 +19,9 @@ import { Label } from "~/components/ui/label";
import { Skeleton } from "~/components/ui/skeleton";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { NumberInput } from "~/components/ui/number-input";
import { api } from "~/trpc/react";
import {
@@ -237,7 +239,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
if (mode === "edit" && isLoadingClient) {
return (
<div className="space-y-6 pb-32">
<DashboardPage className="pb-32">
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
@@ -259,21 +261,20 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</div>
</CardContent>
</Card>
</div>
</DashboardPage>
);
}
return (
<>
<div className="space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title={mode === "edit" ? "Edit Client" : "Add Client"}
description={
mode === "edit"
? "Update client information below"
: "Enter client details below to add a new client."
}
variant="gradient"
>
<Button
type="submit"
@@ -298,9 +299,13 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</>
)}
</Button>
</PageHeader>
</DashboardPageHeader>
<form id="client-form" onSubmit={handleSubmit} className="space-y-6">
<form
id="client-form"
onSubmit={handleSubmit}
className={cn("flex flex-col", dashboardGapClass)}
>
{/* Main Form Container - styled like data table */}
<div className="space-y-4">
{/* Basic Information */}
@@ -508,7 +513,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</Card>
</div>
</form>
</div>
</DashboardPage>
<FloatingActionBar
leftContent={
+2 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { getAppUrl } from "~/lib/app-url";
interface EmailPreviewProps {
subject: string;
@@ -89,10 +90,7 @@ export function EmailPreview({
customMessage: customMessage,
userName: invoice.business?.name ?? "Your Business",
userEmail: fromEmail,
baseUrl:
typeof window !== "undefined"
? window.location.origin
: "https://beenvoice.app",
baseUrl: getAppUrl(),
})
: null;
+101 -143
View File
@@ -2,11 +2,20 @@
import * as React from "react";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Label } from "~/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
pageTabsGridClass,
} from "~/components/layout/page-tabs";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { cn } from "~/lib/utils";
import {
Select,
SelectContent,
@@ -17,7 +26,6 @@ import {
import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input";
import { PageHeader } from "~/components/layout/page-header";
import { InvoiceLineItems } from "./invoice-line-items";
import { InvoiceCalendarView } from "./invoice-calendar-view";
import { EmailPreview } from "./email-preview";
@@ -62,19 +70,17 @@ interface InvoiceFormProps {
function InvoiceFormSkeleton() {
return (
<div className="space-y-6 pb-8">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Loading..."
description="Loading invoice form"
variant="gradient"
/>
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />{" "}
{/* Tabs Skeleton */}
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="bg-muted h-10 w-full animate-pulse rounded-xl p-1" />
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
</div>
</div>
</DashboardPage>
);
}
@@ -95,7 +101,7 @@ function plainTextToHtml(value: string) {
.replace(/\n/g, "<br>");
}
function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
function createDefaultInvoiceFormData(): InvoiceFormData {
return {
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#",
@@ -109,9 +115,7 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
taxRate: 0,
currency: "USD",
defaultHourlyRate: null,
items: blank
? []
: [
items: [
{
id: crypto.randomUUID(),
date: new Date(),
@@ -126,13 +130,11 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const isBlank = searchParams.get("blank") === "1";
const utils = api.useUtils();
// State
const [formData, setFormData] = useState<InvoiceFormData>(() =>
createDefaultInvoiceFormData(isBlank),
createDefaultInvoiceFormData(),
);
const [loading, setLoading] = useState(false);
@@ -140,15 +142,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState("details");
const [previewTab, setPreviewTab] = useState("pdf");
const [previewPinned, setPreviewPinned] = useState(false);
useEffect(() => {
const media = window.matchMedia("(min-width: 1024px)");
const update = () => setPreviewPinned(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
// Queries (Same as before)
const { data: clients, isLoading: loadingClients } =
@@ -179,7 +172,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}, [invoiceId]);
useEffect(() => {
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
// ... (Mapping logic same as before)
const mappedItems: InvoiceItem[] =
existingInvoice.items?.map((item) => ({
id: crypto.randomUUID(),
@@ -231,6 +223,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}
}, [invoiceId, existingInvoice, businesses, initialized]);
useEffect(() => {
if (
invoiceId &&
invoiceId !== "new" &&
existingInvoice &&
!loadingInvoice &&
existingInvoice.status !== "draft"
) {
toast.error("Only draft invoices can be edited");
router.replace(`/dashboard/invoices/${invoiceId}`);
}
}, [invoiceId, existingInvoice, loadingInvoice, router]);
const totals = React.useMemo(() => {
const subtotal = formData.items.reduce(
(sum, item) => sum + item.hours * item.rate,
@@ -456,27 +461,20 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
!initialized ||
loadingClients ||
loadingBusinesses ||
(invoiceId && invoiceId !== "new" && loadingInvoice)
(invoiceId && invoiceId !== "new" && loadingInvoice) ||
(invoiceId &&
invoiceId !== "new" &&
existingInvoice &&
existingInvoice.status !== "draft")
)
return <InvoiceFormSkeleton />;
return (
<>
<div className="page-enter space-y-6 pb-8">
<PageHeader
title={
invoiceId !== "new"
? "Edit Invoice"
: isBlank
? "Blank Invoice"
: "Create Invoice"
}
description={
isBlank
? "Set up a draft to clock time into later"
: "Manage your invoice"
}
variant="gradient"
<DashboardPage>
<DashboardPageHeader
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
description="Manage your invoice"
>
{invoiceId !== "new" && (
<Button
@@ -491,43 +489,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Save className="mr-2 h-4 w-4" />
{loading ? "Saving..." : "Save"}
</Button>
</PageHeader>
</DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
{/* TAB SELECTOR: w-full, p-1, visible background */}
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1 lg:grid-cols-3">
<TabsTrigger
value="details"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Details
</TabsTrigger>
<TabsTrigger
value="items"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Items
</TabsTrigger>
<TabsTrigger
value="timesheet"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Timesheet
</TabsTrigger>
<TabsTrigger
value="preview"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm lg:hidden"
>
Preview
</TabsTrigger>
</TabsList>
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
<PageTabsList>
<PageTabsTrigger value="details">Details</PageTabsTrigger>
<PageTabsTrigger value="items">Items</PageTabsTrigger>
<PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
<PageTabsTrigger value="preview">Preview</PageTabsTrigger>
</PageTabsList>
{/* DETAILS TAB */}
<TabsContent
value="details"
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
>
<PageTabsContent value="details">
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
<Card className="h-full">
<CardHeader>
<CardTitle className="flex gap-2 text-base">
@@ -770,14 +744,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</CardContent>
</Card>
</TabsContent>
</div>
</PageTabsContent>
{/* ITEMS TAB */}
<TabsContent
value="items"
className="mt-6 focus-visible:outline-none"
>
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<PageTabsContent value="items">
<div className={cn(pageTabsGridClass, "md:grid-cols-3")}>
<Card className="bg-primary/5 border-primary/20">
<CardContent className="flex items-center justify-between p-4">
<span className="text-muted-foreground">Total</span>
@@ -826,13 +798,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
{/* TIMESHEET TAB */}
<TabsContent
value="timesheet"
className="mt-6 focus-visible:outline-none"
>
<PageTabsContent value="timesheet">
<Card className="min-h-[600px] w-full">
<CardHeader>
<CardTitle className="flex gap-2">
@@ -850,44 +819,51 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
<TabsContent
value="preview"
className="mt-6 focus-visible:outline-none"
>
<Tabs
value={previewTab}
onValueChange={setPreviewTab}
className="w-full"
>
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1">
<TabsTrigger
value="pdf"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
<PageTabsContent value="preview">
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
Preview
</CardTitle>
<div className="bg-muted flex rounded-lg p-1 text-sm">
<button
type="button"
onClick={() => setPreviewTab("pdf")}
className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
previewTab === "pdf"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
>
PDF
</TabsTrigger>
<TabsTrigger
value="email"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
</button>
<button
type="button"
onClick={() => setPreviewTab("email")}
className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
previewTab === "email"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
>
Email
</TabsTrigger>
</TabsList>
<TabsContent value="pdf" className="mt-6">
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
</TabsContent>
<TabsContent value="email" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
<Mail className="h-5 w-5" /> Email Preview
</CardTitle>
</button>
</div>
</CardHeader>
<CardContent>
<CardContent className="p-0">
{previewTab === "pdf" ? (
<InvoicePdfPreviewPanel
embedded
input={pdfPreviewInput}
enabled={activeTab === "preview" && previewTab === "pdf"}
/>
) : (
<div className="border-t p-6">
<EmailPreview
subject={`Invoice ${formData.invoiceNumber} from ${
selectedBusiness?.name ?? "Your Business"
@@ -926,31 +902,13 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
})),
}}
/>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
<aside className="hidden lg:block">
<div className="sticky top-4 space-y-4">
<InvoicePdfPreviewPanel
input={pdfPreviewInput}
enabled={previewPinned || activeTab === "preview"}
/>
<Card className="border-primary/20 bg-primary/5">
<CardContent className="flex items-center justify-between p-4">
<span className="text-muted-foreground text-sm">Invoice total</span>
<span className="font-mono text-2xl font-bold">
<CountUp value={totals.total} prefix="$" />
</span>
</CardContent>
</Card>
</div>
</aside>
</div>
</div>
</PageTabsContent>
</PageTabs>
</DashboardPage>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
+3 -3
View File
@@ -155,7 +155,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
<div
ref={ref}
className={cn(
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
)}
>
<DatePicker
@@ -260,7 +260,7 @@ function MobileLineItem({
date={item.date}
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
size="sm"
className="w-[92px] shrink-0"
className="w-auto shrink-0"
inputClassName="h-8 px-2 text-xs"
disabled={readOnly}
/>
@@ -374,7 +374,7 @@ export function InvoiceLineItems({
) : null}
<AnimatePresence>
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
<span>Date</span>
<span>Description</span>
<span className="text-center">Hours</span>
@@ -37,6 +37,8 @@ type InvoicePdfPreviewPanelProps = {
enabled?: boolean;
className?: string;
heightClassName?: string;
/** Renders only the preview body (no card/header) for embedding in a parent pane. */
embedded?: boolean;
};
export function InvoicePdfPreviewPanel({
@@ -44,6 +46,7 @@ export function InvoicePdfPreviewPanel({
enabled = true,
className,
heightClassName = "h-[min(80vh,760px)]",
embedded = false,
}: InvoicePdfPreviewPanelProps) {
const previewReady = canPreview(input);
@@ -54,16 +57,7 @@ export function InvoicePdfPreviewPanel({
staleTime: 5_000,
});
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
PDF preview
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
const previewBody = (
<div
className={cn(
"bg-muted/20 overflow-hidden border-t",
@@ -99,7 +93,22 @@ export function InvoicePdfPreviewPanel({
</div>
)}
</div>
</CardContent>
);
if (embedded) {
return <div className={cn("overflow-hidden", className)}>{previewBody}</div>;
}
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
PDF preview
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
</CardTitle>
</CardHeader>
<CardContent className="p-0">{previewBody}</CardContent>
</Card>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { cn } from "~/lib/utils";
/** Vertical rhythm for dashboard pages — use with shell `gap-5`. */
export const dashboardGapClass = "gap-5 md:gap-6";
/** Standard grid gap for dashboard cards and sections. */
export const dashboardGridClass =
"grid gap-5 md:gap-6";
/** Summary stat cards (2-up mobile, 4-up desktop). */
export const dashboardStatGridClass =
"grid grid-cols-2 gap-4 sm:grid-cols-4";
export function DashboardPage({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"page-enter mx-auto flex w-full max-w-7xl flex-col",
dashboardGapClass,
className,
)}
>
{children}
</div>
);
}
export function DashboardGrid({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn(dashboardGridClass, className)}>{children}</div>
);
}
export function DashboardCardTitle({
children,
icon: Icon,
}: {
children: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
}) {
return (
<span className="flex items-center gap-2">
{Icon ? <Icon className="text-muted-foreground h-4 w-4" /> : null}
{children}
</span>
);
}
+23 -26
View File
@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { usePathname } from "next/navigation";
import { Sidebar } from "~/components/layout/sidebar";
import {
SidebarProvider,
@@ -11,23 +12,29 @@ import { Menu } from "lucide-react";
import { Logo } from "~/components/branding/logo";
import { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { useAppearance } from "~/components/providers/appearance-provider";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
import { OnboardingGuard } from "~/components/layout/onboarding-guard";
function DashboardContent({ children }: { children: React.ReactNode }) {
const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance();
const pathname = usePathname();
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
const isOnboarding = pathname === "/dashboard/onboarding";
return (
<div className="bg-dashboard relative flex min-h-screen">
{/* Desktop Sidebar */}
{!isOnboarding && (
<div className="hidden md:block">
<Sidebar />
</div>
)}
{/* Mobile Sidebar (Sheet) */}
<div className="dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden">
<div
className={cn(
"dashboard-mobile-header bg-background/80 border-border fixed top-0 right-0 left-0 z-50 flex min-h-16 items-center border-b px-3 backdrop-blur-md sm:px-4 md:hidden",
isOnboarding && "hidden",
)}
>
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
<SheetTrigger asChild>
<Button
@@ -40,9 +47,9 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
{/* Mobile Link / Logo */}
<div className="ml-4 flex items-center gap-2">
<Logo size="sm" />
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
<Logo size="sm" className="shrink-0" />
<ActiveTimerWidget compact />
</div>
<SheetContent side="left" className="w-72 p-0">
<div className="sr-only">
@@ -53,30 +60,20 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
</Sheet>
</div>
{/* Main Content */}
<main
suppressHydrationWarning
className={cn(
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out",
"md:ml-0",
sidebarStyle === "floating"
? isCollapsed
? "md:ml-24"
: "md:ml-[18rem]"
: isCollapsed
? "md:ml-16"
: "md:ml-64",
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0",
!isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"),
)}
>
<div className="dashboard-content-shell p-4 pt-16 md:pt-4">
<div className="mb-4 md:hidden">
{/* Mobile Breadcrumbs could go here or be part of the page */}
</div>
<div className="mb-4">
<ActiveTimerWidget />
</div>
{children}
{isOnboarding ? (
<OnboardingGuard>{children}</OnboardingGuard>
) : (
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
<OnboardingGuard>{children}</OnboardingGuard>
</div>
)}
</main>
</div>
);
@@ -0,0 +1,29 @@
"use client";
import { createContext, useContext } from "react";
interface DashboardUserContextValue {
isAdmin: boolean;
needsOnboarding: boolean;
}
const DashboardUserContext = createContext<DashboardUserContextValue>({
isAdmin: false,
needsOnboarding: false,
});
export function DashboardUserProvider({
isAdmin,
needsOnboarding,
children,
}: DashboardUserContextValue & { children: React.ReactNode }) {
return (
<DashboardUserContext.Provider value={{ isAdmin, needsOnboarding }}>
{children}
</DashboardUserContext.Provider>
);
}
export function useDashboardUser() {
return useContext(DashboardUserContext);
}
+1 -12
View File
@@ -3,15 +3,11 @@
import React from "react";
import { cn } from "~/lib/utils";
import { Card, CardContent } from "~/components/ui/card";
import { useAppearance } from "~/components/providers/appearance-provider";
import { useSidebar } from "~/components/layout/sidebar-provider";
interface FloatingActionBarProps {
/** Content to display on the left side */
leftContent?: React.ReactNode;
/** Action buttons to display on the right */
children: React.ReactNode;
/** Additional className for styling */
className?: string;
}
@@ -21,19 +17,12 @@ export function FloatingActionBar({
className,
}: FloatingActionBarProps) {
const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance();
return (
<div
className={cn(
"pb-safe-area-inset-bottom fixed right-0 bottom-4 left-0 z-50 transition-all duration-300 ease-in-out",
sidebarStyle === "floating"
? isCollapsed
? "md:left-24"
: "md:left-[18rem]"
: isCollapsed
? "md:left-16"
: "md:left-64",
isCollapsed ? "md:left-16" : "md:left-64",
"animate-slide-in-bottom",
className,
)}
@@ -0,0 +1,24 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useDashboardUser } from "./dashboard-user-context";
export function OnboardingGuard({ children }: { children: React.ReactNode }) {
const { needsOnboarding } = useDashboardUser();
const pathname = usePathname();
const router = useRouter();
const onOnboardingPage = pathname === "/dashboard/onboarding";
useEffect(() => {
if (needsOnboarding && !onOnboardingPage) {
router.replace("/dashboard/onboarding");
}
}, [needsOnboarding, onOnboardingPage, router]);
if (needsOnboarding && !onOnboardingPage) {
return null;
}
return children;
}
+5 -3
View File
@@ -1,5 +1,6 @@
import React from "react";
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
import { cn } from "~/lib/utils";
interface PageHeaderProps {
title: string;
@@ -40,7 +41,7 @@ export function PageHeader({
};
return (
<div className={`animate-fade-in-down mb-6 ${className}`}>
<div className={cn("animate-fade-in-down", className)}>
{variant === "large-gradient" || variant === "gradient" ? (
<div className="platform-header-surface bg-card text-card-foreground relative overflow-hidden rounded-xl border shadow-sm">
<div className="platform-header-gradient from-primary/5 pointer-events-none absolute inset-0 bg-gradient-to-br via-transparent to-transparent" />
@@ -104,8 +105,9 @@ export function DashboardPageHeader({
<PageHeader
title={title}
description={description}
variant="large-gradient"
className={className}
variant="gradient"
className={cn("mb-0", className)}
titleClassName="font-heading text-2xl font-semibold tracking-tight sm:text-3xl"
>
{children}
</PageHeader>
+2 -2
View File
@@ -123,13 +123,13 @@ export function EmptyState({
return (
<div className={cn("py-12 text-center", className)}>
{icon && (
<div className="bg-muted/50 mx-auto mb-4 flex h-16 w-16 items-center justify-center">
<div className="bg-muted mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl p-3 [&_svg]:text-muted-foreground">
{icon}
</div>
)}
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
{description && (
<p className="text-muted-foreground mx-auto mb-4 max-w-sm">
<p className="text-muted-foreground mx-auto mb-4 max-w-sm text-sm">
{description}
</p>
)}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import * as React from "react";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "~/components/ui/tabs";
import { dashboardGapClass, dashboardGridClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
/** Vertical rhythm inside tab panels — matches dashboard page sections. */
export const pageTabsPanelClass = dashboardGapClass;
/** Grid for stacked cards inside a tab panel. */
export const pageTabsGridClass = dashboardGridClass;
type PageTabsProps = React.ComponentPropsWithoutRef<typeof Tabs>;
export function PageTabs({ className, ...props }: PageTabsProps) {
return (
<Tabs
className={cn("flex flex-col", pageTabsPanelClass, className)}
{...props}
/>
);
}
type PageTabsListProps = React.ComponentPropsWithoutRef<typeof TabsList>;
export function PageTabsList({ className, ...props }: PageTabsListProps) {
return (
<div className="-mx-1 overflow-x-auto px-1 pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<TabsList
className={cn(
"bg-muted/50 border-border/60 inline-flex h-10 w-max min-w-full gap-0.5 rounded-xl border p-1 sm:min-w-0",
className,
)}
{...props}
/>
</div>
);
}
export function PageTabsTrigger({
className,
...props
}: React.ComponentPropsWithoutRef<typeof TabsTrigger>) {
return (
<TabsTrigger
className={cn(
"data-[state=active]:bg-background h-8 rounded-lg px-3.5 text-sm data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
);
}
export function PageTabsContent({
className,
...props
}: React.ComponentPropsWithoutRef<typeof TabsContent>) {
return (
<TabsContent
className={cn(
"mt-0 flex flex-col focus-visible:ring-0 focus-visible:outline-none",
pageTabsPanelClass,
className,
)}
{...props}
/>
);
}
+9 -8
View File
@@ -6,7 +6,7 @@ import { authClient } from "~/lib/auth-client";
import { Skeleton } from "~/components/ui/skeleton";
import { Button } from "~/components/ui/button";
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { useSidebar } from "./sidebar-provider";
import { cn } from "~/lib/utils";
import { Logo } from "~/components/branding/logo";
@@ -26,8 +26,9 @@ import {
} from "~/components/ui/dropdown-menu";
import { getGravatarUrl } from "~/lib/gravatar";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { useAppearance } from "~/components/providers/appearance-provider";
import { useAuthSession } from "~/hooks/use-auth-session";
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
interface SidebarProps {
mobile?: boolean;
@@ -37,8 +38,9 @@ interface SidebarProps {
export function Sidebar({ mobile, onClose }: SidebarProps) {
const pathname = usePathname();
const { data: session, isPending } = useAuthSession();
const { isAdmin } = useDashboardUser();
const { isCollapsed, toggleCollapse } = useSidebar();
const { sidebarStyle } = useAppearance();
const navSections = getNavigationForUser(isAdmin);
// If mobile, always expanded
const collapsed = mobile ? false : isCollapsed;
@@ -72,7 +74,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
collapsed && "items-center",
)}
>
{navigationConfig.map((section) => (
{navSections.map((section) => (
<div key={section.title}>
{!collapsed && (
<div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase">
@@ -163,6 +165,8 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
</div>
)}
<ActiveTimerWidget collapsed={collapsed} />
<div
className={cn(
"border-border/50 border-t pt-4",
@@ -265,10 +269,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
return (
<aside
className={cn(
"fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex",
sidebarStyle === "floating"
? "border-border/50 bg-background/80 top-4 bottom-4 left-4 rounded-3xl border shadow-xl backdrop-blur-xl"
: "border-border bg-background top-0 bottom-0 left-0 rounded-none border-r shadow-none",
"border-border bg-background fixed top-0 bottom-0 left-0 z-30 hidden flex-col rounded-none border-r shadow-none transition-all duration-300 ease-in-out md:flex",
isCollapsed ? "w-16" : "w-64",
)}
>
+6 -3
View File
@@ -9,8 +9,11 @@ import {
Users,
} from "lucide-react";
import { BrowserFrame } from "~/components/marketing/browser-frame";
import { getAppHost } from "~/lib/app-url";
import { cn } from "~/lib/utils";
const appHost = getAppHost();
function MockSidebar({ active }: { active: "dashboard" | "invoices" | "time" }) {
const items = [
{ id: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
@@ -87,7 +90,7 @@ export function InvoicesScreenshot({ className }: { className?: string }) {
];
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard/invoices">
<BrowserFrame className={className} url={`${appHost}/dashboard/invoices`}>
<div className="flex min-h-[280px] sm:min-h-[320px]">
<MockSidebar active="invoices" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
@@ -134,7 +137,7 @@ export function InvoicesScreenshot({ className }: { className?: string }) {
export function TimeClockScreenshot({ className }: { className?: string }) {
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard/time-clock">
<BrowserFrame className={className} url={`${appHost}/dashboard/time-clock`}>
<div className="flex min-h-[260px] sm:min-h-[300px]">
<MockSidebar active="time" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
@@ -204,7 +207,7 @@ export function TimeClockScreenshot({ className }: { className?: string }) {
export function DashboardScreenshot({ className }: { className?: string }) {
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard">
<BrowserFrame className={className} url={`${appHost}/dashboard`}>
<div className="flex min-h-[260px] sm:min-h-[300px]">
<MockSidebar active="dashboard" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
+2 -1
View File
@@ -1,9 +1,10 @@
import { getAppHost } from "~/lib/app-url";
import { cn } from "~/lib/utils";
export function BrowserFrame({
children,
className,
url = "beenvoice.app/dashboard",
url = `${getAppHost()}/dashboard`,
}: {
children: React.ReactNode;
className?: string;
@@ -6,7 +6,8 @@ import { usePathname } from "next/navigation";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { useAuthSession } from "~/hooks/use-auth-session";
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
interface SidebarTriggerProps {
isOpen: boolean;
@@ -16,6 +17,8 @@ interface SidebarTriggerProps {
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
const pathname = usePathname();
const { isPending } = useAuthSession();
const { isAdmin } = useDashboardUser();
const navSections = getNavigationForUser(isAdmin);
return (
<>
@@ -34,7 +37,7 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
<div className="bg-background border-border absolute top-full right-0 left-0 z-40 mt-1 border-t">
{/* Navigation content */}
<nav className="flex flex-col p-4">
{navigationConfig.map((section, sectionIndex) => (
{navSections.map((section, sectionIndex) => (
<div
key={section.title}
className={sectionIndex > 0 ? "mt-4" : ""}
@@ -177,7 +177,7 @@ export function AnimationPreferencesProviderSynced({
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time server hydration after local storage
performUpdate(
{
prefersReducedMotion: serverPrefs.prefersReducedMotion,
@@ -187,12 +187,9 @@ export function AnimationPreferencesProviderSynced({
);
}
serverHydratedRef.current = true;
}, [
serverPrefs,
performUpdate,
prefersReducedMotion,
animationSpeedMultiplier,
]);
// One-time hydration from server after local storage is read.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverPrefs]);
const updatePreferences = useCallback<
AnimationPreferencesContextValue["updatePreferences"]
@@ -1,232 +1,88 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isHslChannels } from "~/lib/appearance";
import type { ColorMode, ColorTheme, FontPreference, InterfaceTheme, RadiusPreference, SidebarStyle } from "~/lib/branding";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { defaultColorMode, type ColorMode } from "~/lib/appearance";
import { api } from "~/trpc/react";
import {
AppearanceContext,
applyAppearance,
applyColorMode,
defaultAppearance,
readStoredAppearance,
writeStoredAppearance,
readStoredColorMode,
writeStoredColorMode,
type AppearanceContextValue,
type AppearancePatch,
type AppearancePreferences,
} from "~/components/providers/appearance-provider";
type ServerAppearance = {
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
theme: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: AppearancePreferences["pdfTemplate"];
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
function getServerAppearancePatch(
serverAppearance: ServerAppearance,
): AppearancePatch {
return {
interfaceTheme: serverAppearance.interfaceTheme,
bodyFontPreference: serverAppearance.bodyFontPreference,
headingFontPreference: serverAppearance.headingFontPreference,
radiusPreference: serverAppearance.radiusPreference,
sidebarStyle: serverAppearance.sidebarStyle,
colorMode: serverAppearance.theme,
colorTheme: serverAppearance.colorTheme,
customColor: serverAppearance.customColor,
brandName: serverAppearance.brandName,
brandTagline: serverAppearance.brandTagline,
brandLogoText: serverAppearance.brandLogoText,
brandIcon: serverAppearance.brandIcon,
pdfTemplate: serverAppearance.pdfTemplate,
pdfAccentColor: serverAppearance.pdfAccentColor,
pdfFooterText: serverAppearance.pdfFooterText,
pdfShowLogo: serverAppearance.pdfShowLogo,
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
};
}
/** Dashboard appearance provider with tRPC theme sync. Must render inside TRPCReactProvider. */
/** Dashboard appearance provider with per-user color mode sync. */
export function AppearanceProviderSynced({
children,
}: {
children: React.ReactNode;
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
const serverHydratedRef = useRef(false);
const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({
onSuccess: async () => {
await utils.settings.getTheme.invalidate();
},
const updateMutation = api.settings.updateColorMode.useMutation({
onError: () => {
const cachedAppearance = utils.settings.getTheme.getData();
const fallback = cachedAppearance
? {
...defaultAppearance,
...getServerAppearancePatch(cachedAppearance),
}
: defaultAppearance;
setAppearance(fallback);
applyAppearance(fallback);
writeStoredAppearance(fallback);
const cached = utils.settings.getColorMode.getData();
const fallback = cached?.colorMode ?? defaultColorMode;
setColorMode(fallback);
applyColorMode(fallback);
writeStoredColorMode(fallback);
},
});
const persistAppearance = useCallback(
(patch: AppearancePatch) => {
if (
patch.customColor !== undefined &&
!isHslChannels(patch.customColor)
) {
return;
}
const { data: serverColorMode } = api.settings.getColorMode.useQuery(
undefined,
{
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
},
);
updateMutation.mutate({
interfaceTheme: patch.interfaceTheme,
bodyFontPreference: patch.bodyFontPreference,
headingFontPreference: patch.headingFontPreference,
radiusPreference: patch.radiusPreference,
sidebarStyle: patch.sidebarStyle,
theme: patch.colorMode,
colorTheme: patch.colorTheme,
customColor: patch.customColor,
brandName: patch.brandName,
brandTagline: patch.brandTagline,
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon,
pdfTemplate: patch.pdfTemplate,
pdfAccentColor: patch.pdfAccentColor,
pdfFooterText: patch.pdfFooterText,
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers,
});
useEffect(() => {
const stored = readStoredColorMode();
if (stored) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(stored);
}
}, []);
useEffect(() => {
if (!serverColorMode?.colorMode) return;
if (serverHydratedRef.current) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(serverColorMode.colorMode);
serverHydratedRef.current = true;
}, [serverColorMode?.colorMode]);
useEffect(() => {
applyColorMode(colorMode);
writeStoredColorMode(colorMode);
}, [colorMode]);
const updateAppearance = useCallback(
(patch: AppearancePatch) => {
if (!patch.colorMode) return;
setColorMode(patch.colorMode);
applyColorMode(patch.colorMode);
writeStoredColorMode(patch.colorMode);
updateMutation.mutate({ colorMode: patch.colorMode });
},
[updateMutation],
);
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
useEffect(() => {
const storedAppearance = readStoredAppearance();
if (!storedAppearance) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
}, []);
useEffect(() => {
if (!serverAppearance) return;
const next = getServerAppearancePatch(serverAppearance);
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...next }));
}, [serverAppearance]);
useEffect(() => {
applyAppearance(appearance);
writeStoredAppearance(appearance);
}, [appearance]);
const updateAppearance = useCallback(
(patch: AppearancePatch) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (Object.keys(pendingDebouncedPatchRef.current).length > 0) {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
}
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
persistAppearance(patch);
},
[persistAppearance],
);
const updateAppearanceDebounced = useCallback(
(patch: AppearancePatch) => {
pendingDebouncedPatchRef.current = {
...pendingDebouncedPatchRef.current,
...patch,
};
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
debounceTimerRef.current = null;
}, 500);
},
[persistAppearance],
);
useEffect(
() => () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
pendingDebouncedPatchRef.current = {};
},
[],
);
const value = useMemo<AppearanceContextValue>(
() => ({
...appearance,
...defaultAppearance,
colorMode,
updateAppearance,
updateAppearanceDebounced,
isUpdating: updateMutation.isPending,
}),
[
appearance,
updateAppearance,
updateAppearanceDebounced,
updateMutation.isPending,
],
[colorMode, updateAppearance, updateMutation.isPending],
);
return (
+25 -165
View File
@@ -8,181 +8,53 @@ import {
useMemo,
useState,
} from "react";
import {
fallbackAppearance,
isColorMode,
isColorTheme,
isFontPreference,
isHslChannels,
isInterfaceTheme,
isPdfTemplate,
isRadiusPreference,
isSidebarStyle,
type PdfTemplate,
} from "~/lib/appearance";
import {
defaultBodyFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
brand as defaultBrand,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
import { defaultColorMode, isColorMode, type ColorMode } from "~/lib/appearance";
export type AppearancePreferences = {
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
colorMode: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
export type AppearancePatch = Partial<AppearancePreferences>;
export type AppearanceContextValue = AppearancePreferences & {
updateAppearance: (patch: AppearancePatch) => void;
updateAppearanceDebounced: (patch: AppearancePatch) => void;
isUpdating: boolean;
};
export const STORAGE_KEY = "bv.appearance";
export const defaultAppearance: AppearancePreferences = {
interfaceTheme: defaultInterfaceTheme,
bodyFontPreference: defaultBodyFontPreference,
headingFontPreference: defaultHeadingFontPreference,
radiusPreference: defaultRadiusPreference,
sidebarStyle: defaultSidebarStyle,
colorMode: fallbackAppearance.colorMode,
colorTheme: fallbackAppearance.colorTheme,
brandName: defaultBrand.name,
brandTagline: defaultBrand.tagline,
brandLogoText: defaultBrand.logoText,
brandIcon: defaultBrand.icon,
pdfTemplate: fallbackAppearance.pdfTemplate,
pdfAccentColor: fallbackAppearance.pdfAccentColor,
pdfFooterText: fallbackAppearance.pdfFooterText,
pdfShowLogo: fallbackAppearance.pdfShowLogo,
pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
colorMode: defaultColorMode,
};
export const AppearanceContext =
createContext<AppearanceContextValue | null>(null);
export function readStoredAppearance(): Partial<AppearancePreferences> | null {
export function readStoredColorMode(): ColorMode | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
interfaceTheme: isInterfaceTheme(parsed.interfaceTheme)
? parsed.interfaceTheme
: undefined,
bodyFontPreference: isFontPreference(parsed.bodyFontPreference)
? parsed.bodyFontPreference
: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
headingFontPreference: isFontPreference(parsed.headingFontPreference)
? parsed.headingFontPreference
: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
radiusPreference: isRadiusPreference(parsed.radiusPreference)
? parsed.radiusPreference
: undefined,
sidebarStyle: isSidebarStyle(parsed.sidebarStyle)
? parsed.sidebarStyle
: undefined,
colorMode: isColorMode(parsed.colorMode) ? parsed.colorMode : undefined,
colorTheme: isColorTheme(parsed.colorTheme)
? parsed.colorTheme
: undefined,
customColor: isHslChannels(parsed.customColor)
? parsed.customColor
: undefined,
brandName:
typeof parsed.brandName === "string" ? parsed.brandName : undefined,
brandTagline:
typeof parsed.brandTagline === "string"
? parsed.brandTagline
: undefined,
brandLogoText:
typeof parsed.brandLogoText === "string"
? parsed.brandLogoText
: undefined,
brandIcon:
typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined,
pdfTemplate: isPdfTemplate(parsed.pdfTemplate)
? parsed.pdfTemplate
: undefined,
pdfAccentColor:
typeof parsed.pdfAccentColor === "string"
? parsed.pdfAccentColor
: undefined,
pdfFooterText:
typeof parsed.pdfFooterText === "string"
? parsed.pdfFooterText
: undefined,
pdfShowLogo:
typeof parsed.pdfShowLogo === "boolean"
? parsed.pdfShowLogo
: undefined,
pdfShowPageNumbers:
typeof parsed.pdfShowPageNumbers === "boolean"
? parsed.pdfShowPageNumbers
: undefined,
};
const parsed = JSON.parse(raw) as { colorMode?: unknown };
return isColorMode(parsed.colorMode) ? parsed.colorMode : null;
} catch {
return null;
}
}
export function writeStoredAppearance(prefs: AppearancePreferences) {
export function writeStoredColorMode(colorMode: ColorMode) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
localStorage.setItem(STORAGE_KEY, JSON.stringify({ colorMode }));
} catch {
// Storage can be unavailable in private browsing or locked-down contexts.
}
}
export function applyAppearance(prefs: AppearancePreferences) {
export function applyColorMode(colorMode: ColorMode) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.dataset.interfaceTheme = prefs.interfaceTheme;
root.dataset.bodyFont = prefs.bodyFontPreference;
root.dataset.headingFont = prefs.headingFontPreference;
root.dataset.radius = prefs.radiusPreference;
root.dataset.sidebarStyle = prefs.sidebarStyle;
root.dataset.colorMode = prefs.colorMode;
root.dataset.colorTheme = prefs.colorTheme;
root.classList.toggle("dark", prefs.colorMode === "dark");
if (prefs.customColor) {
root.style.setProperty("--custom-primary", prefs.customColor);
} else {
root.style.removeProperty("--custom-primary");
}
root.dataset.colorMode = colorMode;
root.classList.toggle("dark", colorMode === "dark");
}
/** Local-only appearance provider for marketing and auth pages (no tRPC). */
@@ -191,48 +63,36 @@ export function AppearanceProvider({
}: {
children: React.ReactNode;
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
useEffect(() => {
const storedAppearance = readStoredAppearance();
if (!storedAppearance) return;
const stored = readStoredColorMode();
if (stored) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
setColorMode(stored);
}
}, []);
useEffect(() => {
applyAppearance(appearance);
writeStoredAppearance(appearance);
}, [appearance]);
applyColorMode(colorMode);
writeStoredColorMode(colorMode);
}, [colorMode]);
const updateAppearance = useCallback((patch: AppearancePatch) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
}, []);
const updateAppearanceDebounced = useCallback((patch: AppearancePatch) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
if (patch.colorMode) {
setColorMode(patch.colorMode);
applyColorMode(patch.colorMode);
writeStoredColorMode(patch.colorMode);
}
}, []);
const value = useMemo<AppearanceContextValue>(
() => ({
...appearance,
colorMode,
updateAppearance,
updateAppearanceDebounced,
isUpdating: false,
}),
[appearance, updateAppearance, updateAppearanceDebounced],
[colorMode, updateAppearance],
);
return (
+55 -22
View File
@@ -20,7 +20,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import { ChevronDown, Clock, ExternalLink, Play, Square } from "lucide-react";
import { ChevronDown, Clock, Play, Square } from "lucide-react";
import { toast } from "sonner";
import { cn } from "~/lib/utils";
import {
@@ -30,6 +30,7 @@ import {
import {
describeClockOutOutcome,
formatElapsedSeconds,
formatRunningTimerLabel,
resolveClockDescription,
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
@@ -52,6 +53,19 @@ function invoiceLabel(inv: {
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
}
function entryHref(entry: {
invoiceId: string | null;
clientId: string | null;
invoice?: { id: string } | null;
client?: { id: string } | null;
}): string | null {
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
const clientId = entry.clientId ?? entry.client?.id;
if (clientId) return `/dashboard/clients/${clientId}`;
return null;
}
function ClientChip({
label,
active,
@@ -278,8 +292,7 @@ export function TimeClockPanel({
}
const displayRate = running ? (running.rate ?? 0) : rate;
const runningTitle =
running?.description?.trim() ?? resolveClockDescription("");
const runningTitle = formatRunningTimerLabel(running?.description);
return (
<div className={compact ? "space-y-4" : "space-y-6"}>
@@ -478,7 +491,11 @@ export function TimeClockPanel({
id="clock-stop-note"
value={stopNote}
onChange={(e) => setStopNote(e.target.value)}
placeholder={running?.description || "Update description when you stop"}
placeholder={
running?.description?.trim()
? running.description
: "Update description when you stop"
}
/>
</div>
)}
@@ -517,19 +534,21 @@ export function TimeClockPanel({
<CardHeader>
<CardTitle className="text-base">Today&apos;s entries</CardTitle>
</CardHeader>
<CardContent className="divide-y">
<CardContent>
{todayEntries
.filter((e) => e.endedAt)
.map((entry) => (
<div
key={entry.id}
className="flex items-start justify-between gap-4 py-3 first:pt-0 last:pb-0"
>
.map((entry, index, entries) => {
const href = entryHref(entry);
const isLast = index === entries.length - 1;
const rowClassName = cn(
"flex items-start justify-between gap-4 py-3",
!isLast && "border-border border-b",
);
const content = (
<>
<div className="min-w-0">
<p className="font-medium">
{entry.description || (
<span className="text-muted-foreground italic">No description</span>
)}
{formatRunningTimerLabel(entry.description)}
</p>
<p className="text-muted-foreground text-sm">
{entry.client?.name ?? "No client"}
@@ -546,20 +565,34 @@ export function TimeClockPanel({
<p className="text-muted-foreground">${entry.rate}/hr</p>
) : null}
</div>
</>
);
if (href) {
return (
<Link
key={entry.id}
href={href}
className={cn(
rowClassName,
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
)}
>
{content}
</Link>
);
}
return (
<div key={entry.id} className={rowClassName}>
{content}
</div>
))}
);
})}
</CardContent>
</Card>
) : null}
{compact ? (
<Button variant="link" className="h-auto p-0" asChild>
<Link href="/dashboard/time-clock">
Open full time clock
<ExternalLink className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
) : null}
</div>
);
}
+29 -15
View File
@@ -14,18 +14,23 @@ import {
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
day: "2-digit",
month: "long",
year: "numeric",
};
function formatDate(date: Date | undefined) {
if (!date) {
return "";
}
return date.toLocaleDateString("en-US", {
day: "2-digit",
month: "long",
year: "numeric",
});
return date.toLocaleDateString("en-US", DATE_FORMAT_OPTIONS);
}
// Longest month name in en-US long format (September 30, 2026).
const LONGEST_FORMATTED_DATE = formatDate(new Date(2026, 8, 30));
interface DatePickerProps {
date?: Date;
onDateChange: (date: Date | undefined) => void;
@@ -57,13 +62,7 @@ export function DatePicker({
lg: "h-10 text-sm",
};
const inputWidthClass = className?.includes("w-full")
? "w-full"
: className?.includes("w-32") ||
className?.includes("w-28") ||
className?.includes("w-36")
? className
: "w-full md:w-32 md:min-w-32";
const wantsFullWidth = className?.includes("w-full");
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
@@ -72,16 +71,31 @@ export function DatePicker({
}, [date]);
return (
<div className={cn("relative flex gap-2", inputWidthClass, className)}>
<div
className={cn(
"relative min-w-max",
wantsFullWidth ? "w-full" : "w-auto shrink-0",
className,
)}
>
<span
aria-hidden
className={cn(
"invisible block whitespace-nowrap px-3 pr-10",
sizeClasses[size],
inputClassName,
)}
>
{LONGEST_FORMATTED_DATE}
</span>
<Input
id={id}
value={value}
placeholder={placeholder}
disabled={disabled}
className={cn(
"bg-background pr-10",
"bg-background absolute inset-0 w-full pr-10 tabular-nums",
sizeClasses[size],
"w-full",
inputClassName,
)}
onChange={(e) => {
+2 -2
View File
@@ -64,9 +64,9 @@ function SheetContent({
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto rounded-b-xl border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto rounded-t-xl border-t",
className,
)}
{...props}
+2 -2
View File
@@ -11,7 +11,7 @@ const Tabs = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.Root
ref={ref}
className={cn("flex flex-col gap-1", className)}
className={cn("flex flex-col gap-2", className)}
{...props}
/>
));
@@ -54,7 +54,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
className,
)}
{...props}
-34
View File
@@ -56,31 +56,6 @@ export const env = createEnv({
NEXT_PUBLIC_BRAND_TAGLINE: z.string().optional(),
NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(),
NEXT_PUBLIC_BRAND_ICON: z.string().optional(),
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME: z
.enum([
"beenvoice",
"frutiger",
"frutiger-aero",
"shadcn",
"minimal",
"editorial",
])
.optional(),
NEXT_PUBLIC_DEFAULT_FONT: z
.enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_BODY_FONT: z
.enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_HEADING_FONT: z
.enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_RADIUS: z
.enum(["none", "sm", "md", "lg", "xl"])
.optional(),
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: z
.enum(["floating", "docked"])
.optional(),
},
/**
@@ -109,15 +84,6 @@ export const env = createEnv({
NEXT_PUBLIC_BRAND_TAGLINE: process.env.NEXT_PUBLIC_BRAND_TAGLINE,
NEXT_PUBLIC_BRAND_LOGO_TEXT: process.env.NEXT_PUBLIC_BRAND_LOGO_TEXT,
NEXT_PUBLIC_BRAND_ICON: process.env.NEXT_PUBLIC_BRAND_ICON,
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME:
process.env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME,
NEXT_PUBLIC_DEFAULT_FONT: process.env.NEXT_PUBLIC_DEFAULT_FONT,
NEXT_PUBLIC_DEFAULT_BODY_FONT: process.env.NEXT_PUBLIC_DEFAULT_BODY_FONT,
NEXT_PUBLIC_DEFAULT_HEADING_FONT:
process.env.NEXT_PUBLIC_DEFAULT_HEADING_FONT,
NEXT_PUBLIC_DEFAULT_RADIUS: process.env.NEXT_PUBLIC_DEFAULT_RADIUS,
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE:
process.env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
+6
View File
@@ -0,0 +1,6 @@
export const APP_EMAIL_DOMAIN = "beenvoice.app";
export const PRIVACY_EMAIL = `privacy@${APP_EMAIL_DOMAIN}`;
export const LEGAL_EMAIL = `legal@${APP_EMAIL_DOMAIN}`;
export const SUPPORT_EMAIL = `support@${APP_EMAIL_DOMAIN}`;
export const NOREPLY_EMAIL = `noreply@${APP_EMAIL_DOMAIN}`;
+20
View File
@@ -0,0 +1,20 @@
/** Public app origin (no trailing slash). */
export function getAppUrl(): string {
if (typeof window !== "undefined") {
return window.location.origin.replace(/\/$/, "");
}
const fromEnv = process.env.NEXT_PUBLIC_APP_URL?.trim();
if (fromEnv) return fromEnv.replace(/\/$/, "");
return `http://localhost:${process.env.PORT ?? 3000}`;
}
/** Hostname for display (e.g. marketing browser chrome). */
export function getAppHost(): string {
try {
return new URL(getAppUrl()).host;
} catch {
return "beenvoice.app";
}
}
+4 -102
View File
@@ -1,126 +1,28 @@
import { z } from "zod";
export const interfaceThemeValues = [
"beenvoice",
"frutiger",
"frutiger-aero",
"shadcn",
"minimal",
"editorial",
] as const;
export const fontPreferenceValues = [
"brand",
"frutiger",
"platform",
"inter",
"serif",
] as const;
export const radiusPreferenceValues = ["none", "sm", "md", "lg", "xl"] as const;
export const sidebarStyleValues = ["floating", "docked"] as const;
export const colorModeValues = ["light", "dark", "system"] as const;
export const colorThemeValues = [
"slate",
"blue",
"green",
"rose",
"orange",
"custom",
] as const;
export const pdfTemplateValues = ["classic", "minimal"] as const;
export const interfaceThemeSchema = z.enum(interfaceThemeValues);
export const fontPreferenceSchema = z.enum(fontPreferenceValues);
export const radiusPreferenceSchema = z.enum(radiusPreferenceValues);
export const sidebarStyleSchema = z.enum(sidebarStyleValues);
export const colorModeSchema = z.enum(colorModeValues);
export const colorThemeSchema = z.enum(colorThemeValues);
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
export const hslChannelsSchema = z
.string()
.trim()
.regex(
/^(?:360(?:\.0)?|3[0-5]\d(?:\.\d)?|[12]?\d?\d(?:\.\d)?)\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%$/,
"Use HSL channels like 142.1 76.2% 36.3%",
);
export type InterfaceTheme = z.infer<typeof interfaceThemeSchema>;
export type FontPreference = z.infer<typeof fontPreferenceSchema>;
export type RadiusPreference = z.infer<typeof radiusPreferenceSchema>;
export type SidebarStyle = z.infer<typeof sidebarStyleSchema>;
export type ColorMode = z.infer<typeof colorModeSchema>;
export type ColorTheme = z.infer<typeof colorThemeSchema>;
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
export const fallbackAppearance = {
interfaceTheme: "beenvoice",
fontPreference: "brand",
bodyFontPreference: "brand",
headingFontPreference: "brand",
radiusPreference: "xl",
sidebarStyle: "floating",
colorMode: "system",
colorTheme: "slate",
customColor: undefined,
brandName: "beenvoice",
brandTagline:
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: "beenvoice",
brandIcon: "$",
pdfTemplate: "classic",
export const defaultColorMode: ColorMode = "system";
export const defaultPdfSettings = {
pdfTemplate: "classic" as PdfTemplate,
pdfAccentColor: "#111827",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
} satisfies {
interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
colorMode: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
export function isInterfaceTheme(value: unknown): value is InterfaceTheme {
return interfaceThemeSchema.safeParse(value).success;
}
export function isFontPreference(value: unknown): value is FontPreference {
return fontPreferenceSchema.safeParse(value).success;
}
export function isColorMode(value: unknown): value is ColorMode {
return colorModeSchema.safeParse(value).success;
}
export function isColorTheme(value: unknown): value is ColorTheme {
return colorThemeSchema.safeParse(value).success;
}
export function isRadiusPreference(value: unknown): value is RadiusPreference {
return radiusPreferenceSchema.safeParse(value).success;
}
export function isSidebarStyle(value: unknown): value is SidebarStyle {
return sidebarStyleSchema.safeParse(value).success;
}
export function isPdfTemplate(value: unknown): value is PdfTemplate {
return pdfTemplateSchema.safeParse(value).success;
}
export function isHslChannels(value: unknown): value is string {
return hslChannelsSchema.safeParse(value).success;
}
+3 -7
View File
@@ -3,14 +3,10 @@
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";
function resolveAuthBaseUrl(): string | undefined {
// Always use the current origin in the browser so dev works on any port
// (e.g. 3002 when 3000 is taken), without rebuilding for NEXT_PUBLIC_APP_URL.
if (typeof window !== "undefined") {
return window.location.origin;
}
import { getAppUrl } from "~/lib/app-url";
return process.env.NEXT_PUBLIC_APP_URL;
function resolveAuthBaseUrl(): string | undefined {
return getAppUrl();
}
export const authClient = createAuthClient({
+13
View File
@@ -4,6 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { genericOAuth } from "better-auth/plugins";
import { envBoolean } from "~/lib/env-boolean";
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
import { db } from "~/server/db";
import * as schema from "~/server/db/schema";
@@ -48,6 +49,18 @@ export const auth = betterAuth({
verification: schema.verificationTokens,
},
}),
databaseHooks: {
user: {
create: {
after: async (user) => {
if (isDemoUser(user)) {
return;
}
await promoteFirstRealUserIfNeeded(user.id);
},
},
},
},
trustedOrigins: async (request) => {
const origins = [...staticTrustedOrigins];
+39 -276
View File
@@ -1,292 +1,55 @@
import { env } from "~/env";
import {
fallbackAppearance,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type PdfTemplate,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/appearance";
export type {
ColorMode,
ColorTheme,
FontPreference,
InterfaceTheme,
PdfTemplate,
RadiusPreference,
SidebarStyle,
} from "~/lib/appearance";
import { type ColorMode } from "~/lib/appearance";
export type { ColorMode, PdfTemplate } from "~/lib/appearance";
export {
colorModeSchema,
colorThemeSchema,
fallbackAppearance,
fontPreferenceSchema,
hslChannelsSchema,
interfaceThemeSchema,
defaultColorMode,
defaultPdfSettings,
pdfTemplateSchema,
radiusPreferenceSchema,
sidebarStyleSchema,
} from "~/lib/appearance";
export const interfaceThemes: {
value: InterfaceTheme;
label: string;
description: string;
}[] = [
{
value: "beenvoice",
label: "beenvoice",
description:
"Playfair Display headings, Geist body text, and soft product chrome.",
},
{
value: "frutiger",
label: "Frutiger Airport",
description:
"Rectangular blue-and-yellow wayfinding UI with Frutiger typography and docked navigation.",
},
{
value: "frutiger-aero",
label: "Frutiger Aero",
description:
"Glossy sky-and-glass interface with Frutiger typography and softer surfaces.",
},
{
value: "shadcn",
label: "shadcn/ui",
description: "A plain shadcn baseline for white-label starts.",
},
{
value: "minimal",
label: "Minimal",
description: "Quiet surfaces, lower contrast, and restrained chrome.",
},
{
value: "editorial",
label: "Editorial",
description: "A warmer presentation style for service-led brands.",
},
];
export const themePresets: Record<
InterfaceTheme,
{
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
colorTheme: ColorTheme;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
}
> = {
beenvoice: {
interfaceTheme: "beenvoice",
bodyFontPreference: "brand",
headingFontPreference: "brand",
colorTheme: "slate",
radiusPreference: "xl",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
},
frutiger: {
interfaceTheme: "frutiger",
bodyFontPreference: "frutiger",
headingFontPreference: "frutiger",
colorTheme: "blue",
radiusPreference: "none",
sidebarStyle: "docked",
pdfTemplate: "minimal",
pdfAccentColor: "#003b5c",
},
"frutiger-aero": {
interfaceTheme: "frutiger-aero",
bodyFontPreference: "frutiger",
headingFontPreference: "frutiger",
colorTheme: "blue",
radiusPreference: "lg",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#0077be",
},
shadcn: {
interfaceTheme: "shadcn",
bodyFontPreference: "inter",
headingFontPreference: "inter",
colorTheme: "slate",
radiusPreference: "md",
sidebarStyle: "docked",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
},
minimal: {
interfaceTheme: "minimal",
bodyFontPreference: "platform",
headingFontPreference: "platform",
colorTheme: "slate",
radiusPreference: "sm",
sidebarStyle: "docked",
pdfTemplate: "minimal",
pdfAccentColor: "#111827",
},
editorial: {
interfaceTheme: "editorial",
bodyFontPreference: "platform",
headingFontPreference: "serif",
colorTheme: "rose",
radiusPreference: "lg",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#be123c",
},
};
export const bodyFontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Geist",
description: "Geist body text for the core beenvoice product feel.",
},
{
value: "frutiger",
label: "Frutiger",
description: "Frutiger body text for signage-like operational screens.",
},
{
value: "platform",
label: "Platform",
description: "Native system body text for the current OS.",
},
{
value: "inter",
label: "Geist Legacy",
description: "Legacy sans option mapped to Geist for older installs.",
},
{
value: "serif",
label: "Serif",
description: "Georgia-style body text for editorial deployments.",
},
];
export const headingFontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Playfair Display",
description: "Playfair Display headings for the beenvoice identity.",
},
{
value: "frutiger",
label: "Frutiger",
description: "Frutiger headings for airport-inspired wayfinding.",
},
{
value: "platform",
label: "Platform",
description: "Native system headings for a neutral app feel.",
},
{
value: "inter",
label: "Geist Legacy",
description: "Legacy sans option mapped to Geist for older installs.",
},
{
value: "serif",
label: "Editorial",
description: "Playfair headings with a stronger editorial tone.",
},
];
export const radiusPreferences: {
value: RadiusPreference;
label: string;
description: string;
}[] = [
{ value: "none", label: "Square", description: "No rounded corners." },
{ value: "sm", label: "Small", description: "Subtle 4px rounding." },
{ value: "md", label: "Medium", description: "Standard 8px rounding." },
{ value: "lg", label: "Large", description: "Soft 12px rounding." },
{
value: "xl",
label: "Extra Large",
description: "Expressive 16px rounding.",
},
];
export const sidebarStyles: {
value: SidebarStyle;
label: string;
description: string;
}[] = [
{
value: "floating",
label: "Floating",
description: "Inset navigation with rounded edges and elevation.",
},
{
value: "docked",
label: "Flush",
description: "Full-height navigation aligned to the viewport edge.",
},
];
export const colorThemes: {
value: ColorTheme;
label: string;
swatch: string;
}[] = [
{ value: "slate", label: "Slate", swatch: "hsl(240 5.9% 10%)" },
{ value: "blue", label: "Blue", swatch: "hsl(221.2 83.2% 53.3%)" },
{ value: "green", label: "Green", swatch: "hsl(142.1 76.2% 36.3%)" },
{ value: "rose", label: "Rose", swatch: "hsl(346.8 77.2% 49.8%)" },
{ value: "orange", label: "Orange", swatch: "hsl(24.6 95% 53.1%)" },
];
export const colorModes: {
value: ColorMode;
label: string;
description: string;
}[] = [
{ value: "system", label: "System", description: "Follow device setting." },
{ value: "light", label: "Light", description: "Always use light mode." },
{ value: "dark", label: "Dark", description: "Always use dark mode." },
{
value: "system",
label: "System",
description: "Match your device light or dark setting.",
},
{
value: "light",
label: "Light",
description: "Always use light mode.",
},
{
value: "dark",
label: "Dark",
description: "Always use dark mode.",
},
];
export const defaultInterfaceTheme: InterfaceTheme =
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? fallbackAppearance.interfaceTheme;
export const defaultFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_FONT ?? fallbackAppearance.fontPreference;
export const defaultBodyFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference;
export const defaultHeadingFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference;
export const defaultRadiusPreference: RadiusPreference =
env.NEXT_PUBLIC_DEFAULT_RADIUS ?? fallbackAppearance.radiusPreference;
export const defaultSidebarStyle: SidebarStyle =
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? fallbackAppearance.sidebarStyle;
export const brand = {
name: env.NEXT_PUBLIC_BRAND_NAME ?? fallbackAppearance.brandName,
tagline: env.NEXT_PUBLIC_BRAND_TAGLINE ?? fallbackAppearance.brandTagline,
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? fallbackAppearance.brandLogoText,
icon: env.NEXT_PUBLIC_BRAND_ICON ?? fallbackAppearance.brandIcon,
name: env.NEXT_PUBLIC_BRAND_NAME ?? "beenvoice",
tagline:
env.NEXT_PUBLIC_BRAND_TAGLINE ??
"Simple and efficient invoicing for freelancers and small businesses",
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
};
/** Split logo text for the `$ been` / `voice` styling used in Logo and OG images. */
export function splitLogoText(logoText: string) {
const voiceIndex = logoText.toLowerCase().indexOf("voice");
if (voiceIndex > 0) {
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
}
return [
logoText.slice(0, Math.ceil(logoText.length / 2)),
logoText.slice(Math.ceil(logoText.length / 2)),
] as const;
}
+44
View File
@@ -0,0 +1,44 @@
const DATABASE_SETUP_HINT =
"Database not ready — run `bun db:migrate` (or `bun db:push` for local dev) after starting Postgres.";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function collectErrorParts(error: unknown): string[] {
const parts: string[] = [];
const seen = new Set<unknown>();
let current: unknown = error;
while (current && isRecord(current) && !seen.has(current)) {
seen.add(current);
if (typeof current.message === "string") {
parts.push(current.message);
}
if (typeof current.code === "string") {
parts.push(current.code);
}
current = current.cause;
}
return parts;
}
export function getDatabaseSetupErrorMessage(error: unknown): string | null {
const haystack = collectErrorParts(error).join(" ").toLowerCase();
if (
haystack.includes("does not exist") ||
haystack.includes("42p01") ||
haystack.includes("econnrefused") ||
haystack.includes("connection refused") ||
haystack.includes("connect econnrefused")
) {
return DATABASE_SETUP_HINT;
}
return null;
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { getAppUrl } from "~/lib/app-url";
interface InvoiceEmailTemplateProps {
invoice: {
invoiceNumber: string;
@@ -44,7 +46,7 @@ export function generateInvoiceEmailTemplate({
customMessage,
userName,
userEmail,
baseUrl: _baseUrl = "https://beenvoice.app",
baseUrl = getAppUrl(),
}: InvoiceEmailTemplateProps): { html: string; text: string } {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
@@ -1,4 +1,5 @@
import { formatEmailDate } from "src/lib/email-utils";
import { SUPPORT_EMAIL } from "~/lib/app-email";
interface PasswordResetEmailProps {
userEmail: string;
@@ -188,7 +189,7 @@ export function generatePasswordResetEmailTemplate({
</p>
<p>
beenvoice - Professional invoicing made simple<br>
<a href="mailto:support@beenvoice.com">support@beenvoice.com</a>
<a href="mailto:${SUPPORT_EMAIL}">${SUPPORT_EMAIL}</a>
</p>
</div>
</div>
@@ -213,7 +214,7 @@ SECURITY INFORMATION:
If you're having trouble with the link, copy and paste the entire URL into your browser's address bar.
If you have any questions or need assistance, please contact our support team at support@beenvoice.com.
If you have any questions or need assistance, please contact our support team at ${SUPPORT_EMAIL}.
Best regards,
The beenvoice Team
+82
View File
@@ -0,0 +1,82 @@
import { and, asc, eq, ne, sql } from "drizzle-orm";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
/** Seeded in drizzle/0014_seed_demo_account.sql for App Store review. */
export const DEMO_USER_EMAIL = "demo@example.com";
export const DEMO_USER_ID = "a0000000-0000-4000-8000-000000000001";
const FIRST_USER_ADMIN_LOCK_KEY = 0x62656e76;
type DbTx = Pick<typeof db, "execute" | "select" | "query" | "update">;
export function isDemoUser(user: {
email?: string | null;
id?: string | null;
}): boolean {
const email = user.email?.toLowerCase();
return email === DEMO_USER_EMAIL || user.id === DEMO_USER_ID;
}
function nonDemoUserConditions() {
return and(ne(users.email, DEMO_USER_EMAIL), ne(users.id, DEMO_USER_ID));
}
async function acquireFirstUserAdminLock(tx: DbTx): Promise<void> {
await tx.execute(
sql`SELECT pg_advisory_xact_lock(${FIRST_USER_ADMIN_LOCK_KEY})`,
);
}
/**
* Role for a user about to be inserted. Call inside a transaction before insert.
*/
export async function resolveNewUserRole(tx: DbTx): Promise<"admin" | "user"> {
await acquireFirstUserAdminLock(tx);
const [result] = await tx
.select({ count: sql<number>`count(*)::int` })
.from(users)
.where(nonDemoUserConditions());
return (result?.count ?? 0) === 0 ? "admin" : "user";
}
/**
* Promote the first non-demo user to admin after Better Auth creates them (OAuth, etc.).
* Safe under concurrent sign-ups: only one non-demo admin is ever assigned.
*/
export async function promoteFirstRealUserIfNeeded(userId: string): Promise<void> {
await db.transaction(async (tx) => {
await acquireFirstUserAdminLock(tx);
const user = await tx.query.users.findFirst({
where: eq(users.id, userId),
columns: { id: true, email: true, role: true },
});
if (!user || isDemoUser(user)) {
return;
}
const [adminResult] = await tx
.select({ count: sql<number>`count(*)::int` })
.from(users)
.where(and(nonDemoUserConditions(), eq(users.role, "admin")));
if ((adminResult?.count ?? 0) > 0) {
return;
}
const [firstRealUser] = await tx
.select({ id: users.id })
.from(users)
.where(nonDemoUserConditions())
.orderBy(asc(users.createdAt))
.limit(1);
if (firstRealUser?.id === userId) {
await tx.update(users).set({ role: "admin" }).where(eq(users.id, userId));
}
});
}
+6 -3
View File
@@ -1,5 +1,8 @@
import { getAppUrl } from "~/lib/app-url";
import { LEGAL_EMAIL, PRIVACY_EMAIL } from "~/lib/app-email";
export const LEGAL_LAST_UPDATED = "June 18, 2026";
export const LEGAL_PRIVACY_EMAIL = "privacy@soconnor.dev";
export const LEGAL_TERMS_EMAIL = "legal@soconnor.dev";
export const LEGAL_WEBSITE = "https://beenvoice.soconnor.dev";
export const LEGAL_PRIVACY_EMAIL = PRIVACY_EMAIL;
export const LEGAL_TERMS_EMAIL = LEGAL_EMAIL;
export const LEGAL_WEBSITE = getAppUrl();
+13
View File
@@ -32,6 +32,19 @@ export function isNavLinkActive(pathname: string, href: string): boolean {
return pathname === href;
}
const ADMIN_ONLY_HREFS = new Set(["/dashboard/administration"]);
export function getNavigationForUser(isAdmin: boolean): NavSection[] {
return navigationConfig
.map((section) => ({
...section,
links: section.links.filter(
(link) => isAdmin || !ADMIN_ONLY_HREFS.has(link.href),
),
}))
.filter((section) => section.links.length > 0);
}
export const navigationConfig: NavSection[] = [
{
title: "Main",
+19 -5
View File
@@ -15,6 +15,9 @@ const PLURALIZATION_RULES: Record<
tax: { singular: "Tax", plural: "Taxes" },
category: { singular: "Category", plural: "Categories" },
company: { singular: "Company", plural: "Companies" },
entity: { singular: "Entity", plural: "Entities" },
expense: { singular: "Expense", plural: "Expenses" },
report: { singular: "Report", plural: "Reports" },
};
/**
@@ -105,20 +108,31 @@ export function capitalize(word: string): string {
* Get a properly formatted label for a route segment
*/
export function getRouteLabel(segment: string, isPlural = true): string {
// First, check if it's already in our rules
const rule = PLURALIZATION_RULES[segment.toLowerCase()];
const lower = segment.toLowerCase();
// Route segments are often already plural (e.g. "entities", "invoices")
const ruleByPlural = Object.values(PLURALIZATION_RULES).find(
(r) => r.plural.toLowerCase() === lower,
);
if (ruleByPlural) {
return isPlural ? ruleByPlural.plural : ruleByPlural.singular;
}
const rule = PLURALIZATION_RULES[lower];
if (rule) {
return isPlural ? rule.plural : rule.singular;
}
// If not, try to find it by plural form
const singularForm = singularize(segment);
const singularRule = PLURALIZATION_RULES[singularForm.toLowerCase()];
if (singularRule) {
return isPlural ? singularRule.plural : singularRule.singular;
}
// Otherwise, just capitalize and optionally pluralize
const capitalized = capitalize(segment);
return isPlural ? pluralize(capitalized) : capitalized;
if (isPlural && /s$/i.test(segment)) {
return capitalized;
}
return isPlural ? pluralize(capitalized) : capitalize(singularForm);
}
+17 -2
View File
@@ -1,4 +1,5 @@
export const DEFAULT_CLOCK_DESCRIPTION = "Professional services";
/** Stored on entries clocked in before empty descriptions were allowed. */
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
export function resolveEffectiveHourlyRate(
enteredRate: number,
@@ -21,7 +22,21 @@ export function resolveClockDescription(
const trimmed = title.trim();
if (trimmed) return trimmed;
if (existingDescription?.trim()) return existingDescription.trim();
return DEFAULT_CLOCK_DESCRIPTION;
return "";
}
export function formatRunningTimerLabel(description?: string | null): string {
const trimmed = description?.trim() ?? "";
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) return "Clocked in";
return trimmed;
}
export function resolveBillingDescription(description?: string | null): string {
const trimmed = description?.trim() ?? "";
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) {
return LEGACY_DEFAULT_CLOCK_DESCRIPTION;
}
return trimmed;
}
export type ClockOutOutcome =
+168 -79
View File
@@ -1,59 +1,68 @@
import { and, desc, eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices, clients } from "~/server/db/schema";
import { and, desc, eq, isNotNull, lte } from "drizzle-orm";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { clients, invoices } from "~/server/db/schema";
import type { StoredInvoiceStatus } from "~/types/invoice";
export const dashboardRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
const now = new Date();
type LiteInvoice = {
id: string;
totalAmount: number;
status: string;
dueDate: Date;
issueDate: Date;
};
function buildRevenueMonthKeys(now: Date, count: number) {
const keys: string[] = [];
for (let i = count - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
keys.push(
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`,
);
}
return keys;
}
function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
// 1. Fetch all invoices for the user to calculate stats
// Note: For very large datasets, we should use separate count/sum queries,
// but for typical usage, fetching fields is fine and allows flexible JS calculation
// where SQL complexity might be high (e.g. dynamic status).
// However, let's try to be efficient with SQL where possible.
const userInvoices = await ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
columns: {
id: true,
totalAmount: true,
status: true,
dueDate: true,
issueDate: true,
},
});
const userClientsCount = await ctx.db.$count(
clients,
eq(clients.createdById, userId),
);
// Helper to check status
const getStatus = (inv: (typeof userInvoices)[0]) => {
if (inv.status === "paid") return "paid";
if (inv.status === "draft") return "draft";
if (new Date(inv.dueDate) < now && inv.status !== "paid")
return "overdue";
return "sent";
};
// Calculate Stats
let totalRevenue = 0;
let pendingAmount = 0;
let overdueCount = 0;
let currentMonthRevenue = 0;
let lastMonthRevenue = 0;
const revenueByMonth = Object.fromEntries(
buildRevenueMonthKeys(now, 6).map((key) => [key, 0]),
) as Record<string, number>;
const statusTotals: Record<
string,
{ status: string; count: number; value: number }
> = {};
const monthlyTotals: Record<
string,
{
month: string;
totalInvoices: number;
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
}
> = {};
for (const inv of userInvoices) {
const status = getStatus(inv);
const effectiveStatus = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
const amount = inv.totalAmount;
const issueDate = new Date(inv.issueDate);
if (status === "paid") {
if (effectiveStatus === "paid") {
totalRevenue += amount;
if (issueDate >= currentMonthStart) {
@@ -64,30 +73,52 @@ export const dashboardRouter = createTRPCRouter({
) {
lastMonthRevenue += amount;
}
} else if (status === "sent" || status === "overdue") {
const revenueKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
const monthRevenue = revenueByMonth[revenueKey];
if (monthRevenue !== undefined) {
revenueByMonth[revenueKey] = monthRevenue + amount;
}
} else if (effectiveStatus === "sent" || effectiveStatus === "overdue") {
pendingAmount += amount;
}
if (status === "overdue") {
if (effectiveStatus === "overdue") {
overdueCount++;
}
}
// Revenue Trend (Last 6 months)
const revenueByMonth: Record<string, number> = {};
for (let i = 0; i < 6; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
revenueByMonth[key] = 0;
}
statusTotals[effectiveStatus] ??= {
status: effectiveStatus,
count: 0,
value: 0,
};
statusTotals[effectiveStatus].count += 1;
statusTotals[effectiveStatus].value += amount;
for (const inv of userInvoices) {
if (getStatus(inv) === "paid") {
const d = new Date(inv.issueDate);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
if (revenueByMonth[key] !== undefined) {
revenueByMonth[key] += inv.totalAmount;
}
const monthKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
monthlyTotals[monthKey] ??= {
month: monthKey,
totalInvoices: 0,
paidInvoices: 0,
pendingInvoices: 0,
overdueInvoices: 0,
draftInvoices: 0,
};
monthlyTotals[monthKey].totalInvoices += 1;
switch (effectiveStatus) {
case "paid":
monthlyTotals[monthKey].paidInvoices += 1;
break;
case "sent":
monthlyTotals[monthKey].pendingInvoices += 1;
break;
case "overdue":
monthlyTotals[monthKey].overdueInvoices += 1;
break;
case "draft":
monthlyTotals[monthKey].draftInvoices += 1;
break;
}
}
@@ -102,8 +133,59 @@ export const dashboardRouter = createTRPCRouter({
}))
.sort((a, b) => a.month.localeCompare(b.month));
// Recent Activity
const recentInvoices = await ctx.db.query.invoices.findMany({
const statusChartData = Object.values(statusTotals).map((item) => ({
...item,
name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
}));
const monthlyMetricsChartData = Object.values(monthlyTotals)
.sort((a, b) => a.month.localeCompare(b.month))
.slice(-6)
.map((item) => ({
...item,
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}));
return {
totalRevenue,
pendingAmount,
overdueCount,
revenueChange:
lastMonthRevenue > 0
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
: 0,
revenueChartData,
statusChartData,
monthlyMetricsChartData,
};
}
export const dashboardRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
const now = new Date();
const [
userInvoices,
userClientsCount,
recentInvoices,
currentDraft,
] = await Promise.all([
ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
columns: {
id: true,
totalAmount: true,
status: true,
dueDate: true,
issueDate: true,
},
}),
ctx.db.$count(clients, eq(clients.createdById, userId)),
ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
orderBy: [
desc(invoices.issueDate),
@@ -116,40 +198,47 @@ export const dashboardRouter = createTRPCRouter({
columns: { name: true },
},
},
});
const sendReminderDue = await ctx.db.query.invoices.findMany({
}),
ctx.db.query.invoices.findFirst({
where: and(
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
isNotNull(invoices.sendReminderAt),
lte(invoices.sendReminderAt, now),
),
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
columns: {
id: true,
invoiceNumber: true,
invoicePrefix: true,
sendReminderAt: true,
totalAmount: true,
},
with: {
client: { columns: { name: true } },
items: { columns: { hours: true } },
},
orderBy: [desc(invoices.sendReminderAt)],
limit: 10,
});
}),
]);
const metrics = aggregateDashboardMetrics(userInvoices, now);
return {
totalRevenue,
pendingAmount,
overdueCount,
...metrics,
totalClients: userClientsCount,
revenueChange:
lastMonthRevenue > 0
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
: 0,
revenueChartData,
recentInvoices,
sendReminderDue,
currentDraft: currentDraft
? {
id: currentDraft.id,
invoiceNumber: currentDraft.invoiceNumber,
totalAmount: currentDraft.totalAmount,
client: currentDraft.client,
totalHours: currentDraft.items.reduce(
(sum, item) => sum + item.hours,
0,
),
}
: null,
};
}),
});
+4 -2
View File
@@ -4,6 +4,8 @@ import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices, platformSettings } from "~/server/db/schema";
import { eq } from "drizzle-orm";
import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
@@ -153,7 +155,7 @@ export const emailRouter = createTRPCRouter({
customMessage,
userName,
userEmail,
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
baseUrl: getAppUrl(),
});
// Determine Resend instance and email configuration to use
@@ -177,7 +179,7 @@ export const emailRouter = createTRPCRouter({
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) {
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = invoice.business?.email ?? "noreply@example.com";
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
} else {
throw new Error(
"Email delivery is not configured. Add a Resend API key globally or on this business.",
+4 -13
View File
@@ -12,6 +12,7 @@ import { TRPCError } from "@trpc/server";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { Resend } from "resend";
import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email";
import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
import type { db } from "~/server/db";
@@ -439,20 +440,10 @@ export const invoicesRouter = createTRPCRouter({
});
}
if (items && existingInvoice.status !== "draft") {
if (existingInvoice.status !== "draft") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Line items can only be edited on draft invoices",
});
}
if (
cleanInvoiceData.sendReminderAt !== undefined &&
existingInvoice.status !== "draft"
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Send reminders can only be set on draft invoices",
message: "Only draft invoices can be edited",
});
}
@@ -844,7 +835,7 @@ export const invoicesRouter = createTRPCRouter({
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) {
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = invoice.business?.email ?? "noreply@example.com";
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
} else {
throw new TRPCError({
code: "BAD_REQUEST",
+517 -148
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { and, eq, isNull } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs";
import {
@@ -14,29 +14,20 @@ import {
businesses,
invoices,
invoiceItems,
invoicePayments,
invoiceTemplates,
expenses,
recurringInvoices,
recurringInvoiceItems,
timeEntries,
platformSettings,
} from "~/server/db/schema";
import {
colorModeSchema,
colorThemeSchema,
defaultBodyFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
fallbackAppearance,
fontPreferenceSchema,
hslChannelsSchema,
interfaceThemeSchema,
defaultColorMode,
defaultPdfSettings,
pdfTemplateSchema,
radiusPreferenceSchema,
sidebarStyleSchema,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
import type { db as database } from "~/server/db";
@@ -54,6 +45,24 @@ async function requireAdmin(ctx: {
}
}
function resolveBusinessId(
refs: { businessName?: string; businessNickname?: string },
businessIdMap: Map<string, string>,
): string | null {
if (refs.businessNickname) {
const byNickname = businessIdMap.get(refs.businessNickname);
if (byNickname) return byNickname;
}
if (refs.businessName) {
return businessIdMap.get(refs.businessName) ?? null;
}
return null;
}
function backupImportError(message: string): never {
throw new TRPCError({ code: "BAD_REQUEST", message });
}
// Validation schemas for backup data
const ClientBackupSchema = z.object({
name: z.string(),
@@ -65,6 +74,8 @@ const ClientBackupSchema = z.object({
state: z.string().optional(),
postalCode: z.string().optional(),
country: z.string().optional(),
defaultHourlyRate: z.number().optional(),
currency: z.string().default("USD"),
});
const BusinessBackupSchema = z.object({
@@ -82,10 +93,13 @@ const BusinessBackupSchema = z.object({
taxId: z.string().optional(),
logoUrl: z.string().optional(),
isDefault: z.boolean().default(false),
resendApiKey: z.string().optional(),
resendDomain: z.string().optional(),
emailFromName: z.string().optional(),
});
const InvoiceItemBackupSchema = z.object({
date: z.string().transform((str) => new Date(str)),
date: z.coerce.date(),
description: z.string(),
hours: z.number(),
rate: z.number(),
@@ -95,29 +109,107 @@ const InvoiceItemBackupSchema = z.object({
const InvoiceBackupSchema = z.object({
invoiceNumber: z.string(),
invoicePrefix: z.string().default("#"),
businessName: z.string().optional(),
businessNickname: z.string().optional(),
clientName: z.string(),
issueDate: z.string().transform((str) => new Date(str)),
dueDate: z.string().transform((str) => new Date(str)),
issueDate: z.coerce.date(),
dueDate: z.coerce.date(),
status: z.string().default("draft"),
totalAmount: z.number().default(0),
taxRate: z.number().default(0),
currency: z.string().default("USD"),
notes: z.string().optional(),
emailMessage: z.string().optional(),
items: z.array(InvoiceItemBackupSchema),
});
const InvoicePaymentBackupSchema = z.object({
invoiceNumber: z.string(),
amount: z.number(),
currency: z.string().default("USD"),
date: z.coerce.date(),
method: z.string().default("other"),
notes: z.string().optional(),
});
const InvoiceTemplateBackupSchema = z.object({
name: z.string(),
type: z.string().default("notes"),
content: z.string(),
isDefault: z.boolean().default(false),
});
const RecurringInvoiceItemBackupSchema = z.object({
description: z.string(),
hours: z.number(),
rate: z.number(),
position: z.number().default(0),
});
const RecurringInvoiceBackupSchema = z.object({
name: z.string(),
clientName: z.string(),
businessName: z.string().optional(),
businessNickname: z.string().optional(),
schedule: z.string().default("monthly"),
status: z.string().default("active"),
invoicePrefix: z.string().default("#"),
taxRate: z.number().default(0),
currency: z.string().default("USD"),
notes: z.string().optional(),
emailMessage: z.string().optional(),
nextDueAt: z.coerce.date(),
lastGeneratedAt: z.coerce.date().optional(),
items: z.array(RecurringInvoiceItemBackupSchema),
});
const ExpenseBackupSchema = z.object({
clientName: z.string().optional(),
businessName: z.string().optional(),
businessNickname: z.string().optional(),
invoiceNumber: z.string().optional(),
date: z.coerce.date(),
description: z.string(),
amount: z.number(),
currency: z.string().default("USD"),
category: z.string().optional(),
billable: z.boolean().default(false),
reimbursable: z.boolean().default(false),
taxDeductible: z.boolean().default(false),
notes: z.string().optional(),
});
const TimeEntryBackupSchema = z.object({
description: z.string().default(""),
clientName: z.string().optional(),
invoiceNumber: z.string().optional(),
startedAt: z.coerce.date(),
endedAt: z.coerce.date().optional(),
hours: z.number().optional(),
rate: z.number().optional(),
notes: z.string().optional(),
});
const BackupDataSchema = z.object({
exportDate: z.string(),
version: z.string().default("1.0"),
version: z.string().default("2.0"),
user: z.object({
name: z.string().optional(),
email: z.string(),
prefersReducedMotion: z.boolean().optional(),
animationSpeedMultiplier: z.number().optional(),
theme: z.string().optional(),
onboardingCompletedAt: z.coerce.date().nullable().optional(),
}),
clients: z.array(ClientBackupSchema),
businesses: z.array(BusinessBackupSchema),
invoices: z.array(InvoiceBackupSchema),
invoicePayments: z.array(InvoicePaymentBackupSchema).default([]),
invoiceTemplates: z.array(InvoiceTemplateBackupSchema).default([]),
recurringInvoices: z.array(RecurringInvoiceBackupSchema).default([]),
expenses: z.array(ExpenseBackupSchema).default([]),
timeEntries: z.array(TimeEntryBackupSchema).default([]),
});
export const settingsRouter = createTRPCRouter({
@@ -162,12 +254,45 @@ export const settingsRouter = createTRPCRouter({
email: true,
image: true,
role: true,
onboardingCompletedAt: true,
},
});
return user;
}),
getOnboardingStatus: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
onboardingCompletedAt: true,
},
});
const [businessCount, clientCount] = await Promise.all([
ctx.db.$count(
businesses,
eq(businesses.createdById, ctx.session.user.id),
),
ctx.db.$count(clients, eq(clients.createdById, ctx.session.user.id)),
]);
return {
completed: user?.onboardingCompletedAt != null,
businessCount,
clientCount,
};
}),
completeOnboarding: protectedProcedure.mutation(async ({ ctx }) => {
await ctx.db
.update(users)
.set({ onboardingCompletedAt: new Date() })
.where(eq(users.id, ctx.session.user.id));
return { success: true };
}),
// Get animation preferences
getAnimationPreferences: publicProcedure.query(async ({ ctx }) => {
// Return defaults if not authenticated
@@ -220,64 +345,54 @@ export const settingsRouter = createTRPCRouter({
return { success: true };
}),
// Get theme preferences
getTheme: publicProcedure.query(async ({ ctx }) => {
getColorMode: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: { theme: true },
});
return {
colorMode: (user?.theme as ColorMode) ?? defaultColorMode,
};
}),
updateColorMode: protectedProcedure
.input(
z.object({
colorMode: colorModeSchema,
}),
)
.mutation(async ({ ctx, input }) => {
await ctx.db
.update(users)
.set({ theme: input.colorMode })
.where(eq(users.id, ctx.session.user.id));
return { success: true };
}),
getPdfSettings: publicProcedure.query(async ({ ctx }) => {
const settings = await ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
});
return {
colorTheme:
(settings?.colorTheme as ColorTheme) ?? fallbackAppearance.colorTheme,
customColor: settings?.customColor ?? undefined,
theme: (settings?.theme as ColorMode) ?? fallbackAppearance.colorMode,
interfaceTheme:
(settings?.interfaceTheme as InterfaceTheme) ?? defaultInterfaceTheme,
bodyFontPreference:
(settings?.bodyFontPreference as FontPreference) ??
defaultBodyFontPreference,
headingFontPreference:
(settings?.headingFontPreference as FontPreference) ??
defaultHeadingFontPreference,
radiusPreference:
(settings?.radiusPreference as RadiusPreference) ??
defaultRadiusPreference,
sidebarStyle:
(settings?.sidebarStyle as SidebarStyle) ?? defaultSidebarStyle,
brandName: settings?.brandName ?? fallbackAppearance.brandName,
brandTagline: settings?.brandTagline ?? fallbackAppearance.brandTagline,
brandLogoText:
settings?.brandLogoText ?? fallbackAppearance.brandLogoText,
brandIcon: settings?.brandIcon ?? fallbackAppearance.brandIcon,
pdfTemplate:
(settings?.pdfTemplate as "classic" | "minimal") ??
fallbackAppearance.pdfTemplate,
defaultPdfSettings.pdfTemplate,
pdfAccentColor:
settings?.pdfAccentColor ?? fallbackAppearance.pdfAccentColor,
settings?.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
pdfFooterText:
settings?.pdfFooterText ?? fallbackAppearance.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo ?? fallbackAppearance.pdfShowLogo,
settings?.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
pdfShowPageNumbers:
settings?.pdfShowPageNumbers ?? fallbackAppearance.pdfShowPageNumbers,
settings?.pdfShowPageNumbers ?? defaultPdfSettings.pdfShowPageNumbers,
};
}),
// Update theme preferences
updateTheme: protectedProcedure
updatePdfSettings: protectedProcedure
.input(
z.object({
colorTheme: colorThemeSchema.optional(),
customColor: hslChannelsSchema.optional(),
theme: colorModeSchema.optional(),
interfaceTheme: interfaceThemeSchema.optional(),
bodyFontPreference: fontPreferenceSchema.optional(),
headingFontPreference: fontPreferenceSchema.optional(),
radiusPreference: radiusPreferenceSchema.optional(),
sidebarStyle: sidebarStyleSchema.optional(),
brandName: z.string().min(1).max(100).optional(),
brandTagline: z.string().min(1).max(255).optional(),
brandLogoText: z.string().min(1).max(100).optional(),
brandIcon: z.string().min(1).max(20).optional(),
pdfTemplate: pdfTemplateSchema.optional(),
pdfAccentColor: z.string().min(4).max(50).optional(),
pdfFooterText: z.string().min(1).max(120).optional(),
@@ -291,57 +406,18 @@ export const settingsRouter = createTRPCRouter({
.insert(platformSettings)
.values({
id: "global",
brandName: input.brandName ?? fallbackAppearance.brandName,
brandTagline: input.brandTagline ?? fallbackAppearance.brandTagline,
brandLogoText:
input.brandLogoText ?? fallbackAppearance.brandLogoText,
brandIcon: input.brandIcon ?? fallbackAppearance.brandIcon,
colorTheme: input.colorTheme ?? fallbackAppearance.colorTheme,
customColor: input.customColor,
theme: input.theme ?? fallbackAppearance.colorMode,
interfaceTheme: input.interfaceTheme ?? defaultInterfaceTheme,
bodyFontPreference:
input.bodyFontPreference ?? defaultBodyFontPreference,
headingFontPreference:
input.headingFontPreference ?? defaultHeadingFontPreference,
radiusPreference: input.radiusPreference ?? defaultRadiusPreference,
sidebarStyle: input.sidebarStyle ?? defaultSidebarStyle,
pdfTemplate: input.pdfTemplate ?? fallbackAppearance.pdfTemplate,
pdfTemplate: input.pdfTemplate ?? defaultPdfSettings.pdfTemplate,
pdfAccentColor:
input.pdfAccentColor ?? fallbackAppearance.pdfAccentColor,
input.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
pdfFooterText:
input.pdfFooterText ?? fallbackAppearance.pdfFooterText,
pdfShowLogo: input.pdfShowLogo ?? fallbackAppearance.pdfShowLogo,
input.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
pdfShowLogo: input.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
pdfShowPageNumbers:
input.pdfShowPageNumbers ?? fallbackAppearance.pdfShowPageNumbers,
input.pdfShowPageNumbers ?? defaultPdfSettings.pdfShowPageNumbers,
})
.onConflictDoUpdate({
target: platformSettings.id,
set: {
...(input.brandName && { brandName: input.brandName }),
...(input.brandTagline && { brandTagline: input.brandTagline }),
...(input.brandLogoText && {
brandLogoText: input.brandLogoText,
}),
...(input.brandIcon && { brandIcon: input.brandIcon }),
...(input.colorTheme && { colorTheme: input.colorTheme }),
...(input.customColor !== undefined && {
customColor: input.customColor,
}),
...(input.theme && { theme: input.theme }),
...(input.interfaceTheme && {
interfaceTheme: input.interfaceTheme,
}),
...(input.bodyFontPreference && {
bodyFontPreference: input.bodyFontPreference,
}),
...(input.headingFontPreference && {
headingFontPreference: input.headingFontPreference,
}),
...(input.radiusPreference && {
radiusPreference: input.radiusPreference,
}),
...(input.sidebarStyle && { sidebarStyle: input.sidebarStyle }),
...(input.pdfTemplate && { pdfTemplate: input.pdfTemplate }),
...(input.pdfAccentColor && {
pdfAccentColor: input.pdfAccentColor,
@@ -469,20 +545,21 @@ export const settingsRouter = createTRPCRouter({
exportData: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
// Get user info
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
columns: {
name: true,
email: true,
prefersReducedMotion: true,
animationSpeedMultiplier: true,
theme: true,
onboardingCompletedAt: true,
},
});
// Get all clients
const userClients = await ctx.db.query.clients.findMany({
where: eq(clients.createdById, userId),
columns: {
id: true,
name: true,
email: true,
phone: true,
@@ -492,14 +569,14 @@ export const settingsRouter = createTRPCRouter({
state: true,
postalCode: true,
country: true,
defaultHourlyRate: true,
currency: true,
},
});
// Get all businesses
const userBusinesses = await ctx.db.query.businesses.findMany({
where: eq(businesses.createdById, userId),
columns: {
id: true,
name: true,
nickname: true,
email: true,
@@ -514,24 +591,27 @@ export const settingsRouter = createTRPCRouter({
taxId: true,
logoUrl: true,
isDefault: true,
resendApiKey: true,
resendDomain: true,
emailFromName: true,
},
});
const userInvoiceTemplates = await ctx.db.query.invoiceTemplates.findMany({
where: eq(invoiceTemplates.createdById, userId),
columns: {
name: true,
type: true,
content: true,
isDefault: true,
},
});
// Get all invoices with their items
const userInvoices = await ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
with: {
client: {
columns: {
name: true,
},
},
business: {
columns: {
name: true,
nickname: true,
},
},
client: { columns: { name: true } },
business: { columns: { name: true, nickname: true } },
items: {
columns: {
date: true,
@@ -555,13 +635,61 @@ export const settingsRouter = createTRPCRouter({
],
});
// Format the data for export
const backupData = {
const userPayments = await ctx.db.query.invoicePayments.findMany({
where: eq(invoicePayments.createdById, userId),
with: {
invoice: { columns: { invoiceNumber: true } },
},
});
const userRecurringInvoices = await ctx.db.query.recurringInvoices.findMany({
where: eq(recurringInvoices.createdById, userId),
with: {
client: { columns: { name: true } },
business: { columns: { name: true, nickname: true } },
items: {
columns: {
description: true,
hours: true,
rate: true,
position: true,
},
orderBy: (items, { asc }) => [
asc(items.position),
asc(items.createdAt),
],
},
},
});
const userExpenses = await ctx.db.query.expenses.findMany({
where: eq(expenses.createdById, userId),
with: {
client: { columns: { name: true } },
business: { columns: { name: true, nickname: true } },
invoice: { columns: { invoiceNumber: true } },
},
});
const userTimeEntries = await ctx.db.query.timeEntries.findMany({
where: eq(timeEntries.createdById, userId),
with: {
client: { columns: { name: true } },
invoice: { columns: { invoiceNumber: true } },
},
orderBy: (entries, { asc }) => [asc(entries.startedAt)],
});
return {
exportDate: new Date().toISOString(),
version: "1.0",
version: "2.0",
user: {
name: user?.name ?? "",
email: user?.email ?? "",
prefersReducedMotion: user?.prefersReducedMotion ?? false,
animationSpeedMultiplier: user?.animationSpeedMultiplier ?? 1,
theme: user?.theme ?? "system",
onboardingCompletedAt: user?.onboardingCompletedAt ?? null,
},
clients: userClients.map((client) => ({
name: client.name,
@@ -573,6 +701,8 @@ export const settingsRouter = createTRPCRouter({
state: client.state ?? undefined,
postalCode: client.postalCode ?? undefined,
country: client.country ?? undefined,
defaultHourlyRate: client.defaultHourlyRate ?? undefined,
currency: client.currency,
})),
businesses: userBusinesses.map((business) => ({
name: business.name,
@@ -589,24 +719,82 @@ export const settingsRouter = createTRPCRouter({
taxId: business.taxId ?? undefined,
logoUrl: business.logoUrl ?? undefined,
isDefault: business.isDefault ?? false,
resendApiKey: business.resendApiKey ?? undefined,
resendDomain: business.resendDomain ?? undefined,
emailFromName: business.emailFromName ?? undefined,
})),
invoiceTemplates: userInvoiceTemplates.map((template) => ({
name: template.name,
type: template.type,
content: template.content,
isDefault: template.isDefault,
})),
invoices: userInvoices.map((invoice) => ({
invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix ?? "#",
businessName: invoice.business?.name,
businessNickname: invoice.business?.nickname,
businessNickname: invoice.business?.nickname ?? undefined,
clientName: invoice.client.name,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status,
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
currency: invoice.currency,
notes: invoice.notes ?? undefined,
emailMessage: invoice.emailMessage ?? undefined,
items: invoice.items,
})),
invoicePayments: userPayments.map((payment) => ({
invoiceNumber: payment.invoice.invoiceNumber,
amount: payment.amount,
currency: payment.currency,
date: payment.date,
method: payment.method,
notes: payment.notes ?? undefined,
})),
recurringInvoices: userRecurringInvoices.map((recurring) => ({
name: recurring.name,
clientName: recurring.client.name,
businessName: recurring.business?.name,
businessNickname: recurring.business?.nickname ?? undefined,
schedule: recurring.schedule,
status: recurring.status,
invoicePrefix: recurring.invoicePrefix ?? "#",
taxRate: recurring.taxRate,
currency: recurring.currency,
notes: recurring.notes ?? undefined,
emailMessage: recurring.emailMessage ?? undefined,
nextDueAt: recurring.nextDueAt,
lastGeneratedAt: recurring.lastGeneratedAt ?? undefined,
items: recurring.items,
})),
expenses: userExpenses.map((expense) => ({
clientName: expense.client?.name,
businessName: expense.business?.name,
businessNickname: expense.business?.nickname ?? undefined,
invoiceNumber: expense.invoice?.invoiceNumber,
date: expense.date,
description: expense.description,
amount: expense.amount,
currency: expense.currency,
category: expense.category ?? undefined,
billable: expense.billable,
reimbursable: expense.reimbursable,
taxDeductible: expense.taxDeductible,
notes: expense.notes ?? undefined,
})),
timeEntries: userTimeEntries.map((entry) => ({
description: entry.description,
clientName: entry.client?.name,
invoiceNumber: entry.invoice?.invoiceNumber,
startedAt: entry.startedAt,
endedAt: entry.endedAt ?? undefined,
hours: entry.hours ?? undefined,
rate: entry.rate ?? undefined,
notes: entry.notes ?? undefined,
})),
};
return backupData;
}),
// Import user data (restore)
@@ -615,12 +803,12 @@ export const settingsRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
try {
return await ctx.db.transaction(async (tx) => {
// Create a map to track old to new client IDs
const clientIdMap = new Map<string, string>();
const businessIdMap = new Map<string, string>();
const invoiceIdMap = new Map<string, string>();
// Import clients
for (const clientData of input.clients) {
const [newClient] = await tx
.insert(clients)
@@ -635,7 +823,6 @@ export const settingsRouter = createTRPCRouter({
}
}
// Import businesses
for (const businessData of input.businesses) {
const [newBusiness] = await tx
.insert(businesses)
@@ -653,26 +840,28 @@ export const settingsRouter = createTRPCRouter({
}
}
// Import invoices
for (const templateData of input.invoiceTemplates) {
await tx.insert(invoiceTemplates).values({
...templateData,
createdById: userId,
});
}
for (const invoiceData of input.invoices) {
const clientId = clientIdMap.get(invoiceData.clientName);
if (!clientId) {
throw new Error(`Client ${invoiceData.clientName} not found`);
backupImportError(
`Client "${invoiceData.clientName}" not found for invoice ${invoiceData.invoiceNumber}`,
);
}
const businessId = invoiceData.businessNickname
? (businessIdMap.get(invoiceData.businessNickname) ??
(invoiceData.businessName
? (businessIdMap.get(invoiceData.businessName) ?? null)
: null))
: invoiceData.businessName
? (businessIdMap.get(invoiceData.businessName) ?? null)
: null;
const businessId = resolveBusinessId(invoiceData, businessIdMap);
const [newInvoice] = await tx
.insert(invoices)
.values({
invoiceNumber: invoiceData.invoiceNumber,
invoicePrefix: invoiceData.invoicePrefix,
businessId,
clientId,
issueDate: invoiceData.issueDate,
@@ -680,14 +869,17 @@ export const settingsRouter = createTRPCRouter({
status: invoiceData.status,
totalAmount: invoiceData.totalAmount,
taxRate: invoiceData.taxRate,
currency: invoiceData.currency,
notes: invoiceData.notes,
emailMessage: invoiceData.emailMessage,
createdById: userId,
})
.returning({ id: invoices.id });
if (newInvoice && invoiceData.items.length > 0) {
// Import invoice items
if (newInvoice) {
invoiceIdMap.set(invoiceData.invoiceNumber, newInvoice.id);
if (invoiceData.items.length > 0) {
await tx.insert(invoiceItems).values(
invoiceData.items.map((item) => ({
...item,
@@ -696,20 +888,197 @@ export const settingsRouter = createTRPCRouter({
);
}
}
}
for (const paymentData of input.invoicePayments) {
const invoiceId = invoiceIdMap.get(paymentData.invoiceNumber);
if (!invoiceId) {
backupImportError(
`Invoice "${paymentData.invoiceNumber}" not found for payment`,
);
}
await tx.insert(invoicePayments).values({
invoiceId,
amount: paymentData.amount,
currency: paymentData.currency,
date: paymentData.date,
method: paymentData.method,
notes: paymentData.notes,
createdById: userId,
});
}
for (const recurringData of input.recurringInvoices) {
const clientId = clientIdMap.get(recurringData.clientName);
if (!clientId) {
backupImportError(
`Client "${recurringData.clientName}" not found for recurring invoice "${recurringData.name}"`,
);
}
const businessId = resolveBusinessId(recurringData, businessIdMap);
const [newRecurring] = await tx
.insert(recurringInvoices)
.values({
name: recurringData.name,
clientId,
businessId,
schedule: recurringData.schedule,
status: recurringData.status,
invoicePrefix: recurringData.invoicePrefix,
taxRate: recurringData.taxRate,
currency: recurringData.currency,
notes: recurringData.notes,
emailMessage: recurringData.emailMessage,
nextDueAt: recurringData.nextDueAt,
lastGeneratedAt: recurringData.lastGeneratedAt,
createdById: userId,
})
.returning({ id: recurringInvoices.id });
if (newRecurring && recurringData.items.length > 0) {
await tx.insert(recurringInvoiceItems).values(
recurringData.items.map((item) => ({
...item,
recurringInvoiceId: newRecurring.id,
})),
);
}
}
for (const expenseData of input.expenses) {
const clientId = expenseData.clientName
? (clientIdMap.get(expenseData.clientName) ?? null)
: null;
if (expenseData.clientName && !clientId) {
backupImportError(
`Client "${expenseData.clientName}" not found for expense "${expenseData.description}"`,
);
}
const businessId = resolveBusinessId(expenseData, businessIdMap);
const invoiceId = expenseData.invoiceNumber
? (invoiceIdMap.get(expenseData.invoiceNumber) ?? null)
: null;
if (expenseData.invoiceNumber && !invoiceId) {
backupImportError(
`Invoice "${expenseData.invoiceNumber}" not found for expense "${expenseData.description}"`,
);
}
await tx.insert(expenses).values({
clientId,
businessId,
invoiceId,
date: expenseData.date,
description: expenseData.description,
amount: expenseData.amount,
currency: expenseData.currency,
category: expenseData.category,
billable: expenseData.billable,
reimbursable: expenseData.reimbursable,
taxDeductible: expenseData.taxDeductible,
notes: expenseData.notes,
createdById: userId,
});
}
const existingRunningEntry = await tx.query.timeEntries.findFirst({
where: and(
eq(timeEntries.createdById, userId),
isNull(timeEntries.endedAt),
),
columns: { id: true },
});
for (const entryData of input.timeEntries) {
const clientId = entryData.clientName
? (clientIdMap.get(entryData.clientName) ?? null)
: null;
if (entryData.clientName && !clientId) {
backupImportError(
`Client "${entryData.clientName}" not found for time entry`,
);
}
const invoiceId = entryData.invoiceNumber
? (invoiceIdMap.get(entryData.invoiceNumber) ?? null)
: null;
if (entryData.invoiceNumber && !invoiceId) {
backupImportError(
`Invoice "${entryData.invoiceNumber}" not found for time entry`,
);
}
const isRunningImport = entryData.endedAt == null;
const endedAt =
isRunningImport && existingRunningEntry
? entryData.startedAt
: entryData.endedAt;
await tx.insert(timeEntries).values({
description: entryData.description,
clientId,
invoiceId,
startedAt: entryData.startedAt,
endedAt,
hours: entryData.hours,
rate: entryData.rate,
notes: entryData.notes,
createdById: userId,
});
}
await tx
.update(users)
.set({
...(input.user.prefersReducedMotion !== undefined && {
prefersReducedMotion: input.user.prefersReducedMotion,
}),
...(input.user.animationSpeedMultiplier !== undefined && {
animationSpeedMultiplier: input.user.animationSpeedMultiplier,
}),
...(input.user.theme !== undefined && { theme: input.user.theme }),
...(input.user.onboardingCompletedAt !== undefined && {
onboardingCompletedAt: input.user.onboardingCompletedAt,
}),
})
.where(eq(users.id, userId));
return {
success: true,
imported: {
clients: input.clients.length,
businesses: input.businesses.length,
invoiceTemplates: input.invoiceTemplates.length,
invoices: input.invoices.length,
items: input.invoices.reduce(
invoiceItems: input.invoices.reduce(
(sum, inv) => sum + inv.items.length,
0,
),
invoicePayments: input.invoicePayments.length,
recurringInvoices: input.recurringInvoices.length,
recurringInvoiceItems: input.recurringInvoices.reduce(
(sum, inv) => sum + inv.items.length,
0,
),
expenses: input.expenses.length,
timeEntries: input.timeEntries.length,
},
};
});
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error ? error.message : "Import failed unexpectedly",
});
}
}),
// Get data statistics
+6 -4
View File
@@ -6,6 +6,7 @@ import { TRPCError } from "@trpc/server";
import type { db } from "~/server/db";
import {
computeTrackedHours,
resolveBillingDescription,
type ClockOutOutcome,
} from "~/lib/time-clock";
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
@@ -457,12 +458,13 @@ export const timeEntriesRouter = createTRPCRouter({
const endedAt = new Date();
const hours = computeHours(entry.startedAt, endedAt);
const description = input?.description?.trim() ?? entry.description;
const rawDescription = input?.description?.trim() ?? entry.description?.trim() ?? "";
const billingDescription = resolveBillingDescription(rawDescription);
const rate = entry.rate ?? 0;
const [updated] = await ctx.db
.update(timeEntries)
.set({ endedAt, hours, description, updatedAt: new Date() })
.set({ endedAt, hours, description: rawDescription, updatedAt: new Date() })
.where(eq(timeEntries.id, entry.id))
.returning();
@@ -478,7 +480,7 @@ export const timeEntriesRouter = createTRPCRouter({
ctx.session.user.id,
entry.invoiceId,
updated.id,
description,
billingDescription,
hours,
rate,
endedAt,
@@ -490,7 +492,7 @@ export const timeEntriesRouter = createTRPCRouter({
ctx.session.user.id,
entry.clientId,
updated.id,
description,
billingDescription,
hours,
rate,
endedAt,
+3 -8
View File
@@ -133,17 +133,12 @@ export const createTRPCRouter = t.router;
*/
const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now();
const result = await next();
const end = Date.now();
if (t._config.isDev) {
// artificial delay in dev
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
const result = await next();
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
}
return result;
});
+1 -25
View File
@@ -32,37 +32,13 @@ export const users = createTable("user", (d) => ({
// Custom fields
prefersReducedMotion: d.boolean().default(false).notNull(),
animationSpeedMultiplier: d.real().default(1).notNull(),
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
customColor: d.varchar({ length: 50 }),
theme: d.varchar({ length: 20 }).default("system").notNull(),
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
fontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
role: d.varchar({ length: 20 }).default("user").notNull(),
onboardingCompletedAt: d.timestamp(),
}));
export const platformSettings = createTable("platform_setting", (d) => ({
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
brandName: d.varchar({ length: 100 }).default("beenvoice").notNull(),
brandTagline: d
.varchar({ length: 255 })
.default(
"Simple and efficient invoicing for freelancers and small businesses",
)
.notNull(),
brandLogoText: d.varchar({ length: 100 }).default("beenvoice").notNull(),
brandIcon: d.varchar({ length: 20 }).default("$").notNull(),
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
customColor: d.varchar({ length: 50 }),
theme: d.varchar({ length: 20 }).default("system").notNull(),
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(),
pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(),
pdfFooterText: d
+21 -576
View File
@@ -3,218 +3,37 @@
@layer base {
:root {
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
--background: 0 0% 100%;
/* #FFFFFF */
--foreground: 240 10% 3.9%;
/* #09090B */
--card: 0 0% 100%;
/* #FFFFFF */
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
/* #18181B */
--primary-foreground: 0 0% 98%;
/* #FAFAFA */
--secondary: 240 4.8% 90%;
/* #E4E4E7 (Darkened for contrast) */
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
/* #F4F4F5 */
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
/* #E4E4E7 */
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 1rem;
/* 16px Global Radius */
}
:root[data-interface-theme="shadcn"] {
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--radius: 0.5rem;
}
:root[data-interface-theme="beenvoice"] {
--secondary: 240 4.8% 90%;
--secondary-foreground: 240 5.9% 10%;
--radius: 1rem;
}
:root[data-interface-theme="frutiger"] {
--background: 0 0% 100%;
--foreground: 210 24% 8%;
--card: 0 0% 100%;
--card-foreground: 210 24% 8%;
--popover: 0 0% 100%;
--popover-foreground: 210 24% 8%;
--primary: 203 100% 18%;
--primary-foreground: 46 100% 91%;
--secondary: 47 100% 50%;
--secondary-foreground: 210 24% 8%;
--muted: 199 73% 91%;
--muted-foreground: 203 52% 24%;
--accent: 47 100% 50%;
--accent-foreground: 210 24% 8%;
--border: 203 45% 42%;
--input: 203 45% 42%;
--ring: 203 100% 18%;
--radius: 0rem;
}
:root[data-interface-theme="frutiger-aero"] {
--background: 190 76% 96%;
--foreground: 205 50% 12%;
--card: 0 0% 100%;
--card-foreground: 205 50% 12%;
--popover: 0 0% 100%;
--popover-foreground: 205 50% 12%;
--primary: 201 100% 37%;
--primary-foreground: 0 0% 100%;
--secondary: 104 55% 55%;
--secondary-foreground: 205 50% 12%;
--muted: 190 56% 90%;
--muted-foreground: 205 32% 32%;
--accent: 104 55% 55%;
--accent-foreground: 205 50% 12%;
--border: 195 48% 74%;
--input: 195 48% 74%;
--ring: 201 100% 37%;
--radius: 0.75rem;
}
:root[data-interface-theme="minimal"] {
--background: 0 0% 100%;
--card: 0 0% 100%;
--popover: 0 0% 100%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 96.5%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 97%;
--accent: 240 4.8% 96%;
--accent-foreground: 240 5.9% 10%;
}
:root[data-interface-theme="editorial"] {
--background: 36 33% 98%;
--card: 36 33% 99%;
--popover: 36 33% 99%;
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 30 18% 91%;
--secondary-foreground: 24 10% 10%;
--muted: 30 20% 94%;
--accent: 346.8 77.2% 49.8%;
--accent-foreground: 355.7 100% 97.3%;
--border: 30 15% 86%;
--input: 30 15% 86%;
}
:root[data-body-font="brand"],
:root[data-body-font="inter"] {
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
:root[data-body-font="frutiger"] {
--app-font-sans: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
}
:root[data-body-font="platform"] {
--app-font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-body-font="serif"] {
--app-font-sans:
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
}
:root[data-heading-font="brand"],
:root[data-heading-font="serif"] {
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-heading-font="frutiger"] {
--app-font-heading: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
}
:root[data-heading-font="platform"] {
--app-font-heading:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-heading-font="inter"] {
--app-font-heading: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="brand"]:not([data-body-font]) {
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-font="frutiger"]:not([data-body-font]) {
--app-font-sans: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="platform"]:not([data-body-font]) {
--app-font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
--app-font-heading:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-font="inter"]:not([data-body-font]) {
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="serif"]:not([data-body-font]) {
--app-font-sans:
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-radius="none"] {
--radius: 0rem;
}
:root[data-radius="sm"] {
--radius: 0.25rem;
}
:root[data-radius="md"] {
--radius: 0.5rem;
}
:root[data-radius="lg"] {
--radius: 0.75rem;
}
:root[data-radius="xl"] {
--radius: 1rem;
--dashboard-mobile-header-height: 4rem;
--dashboard-mobile-header-gap: 0.75rem;
}
:root[data-color-mode="dark"],
:root.dark {
--background: 240 10% 3.9%;
/* #09090B */
--foreground: 0 0% 98%;
/* #FAFAFA */
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
@@ -222,7 +41,6 @@
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 20%;
/* #27272A */
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
@@ -231,7 +49,6 @@
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
/* #27272A */
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
@@ -239,9 +56,7 @@
@media (prefers-color-scheme: dark) {
:root:not([data-color-mode="light"]) {
--background: 240 10% 3.9%;
/* #09090B */
--foreground: 0 0% 98%;
/* #FAFAFA */
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
@@ -249,7 +64,6 @@
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 20%;
/* #27272A */
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
@@ -258,110 +72,10 @@
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
/* #27272A */
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
:root[data-color-theme="slate"] {
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
}
:root[data-color-theme="blue"] {
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--accent: 217.2 91.2% 59.8%;
--accent-foreground: 210 40% 98%;
}
:root[data-color-theme="green"] {
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--accent: 142.1 70.6% 45.3%;
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-color-theme="rose"] {
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--accent: 346.8 77.2% 49.8%;
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-color-theme="orange"] {
--primary: 24.6 95% 53.1%;
--primary-foreground: 60 9.1% 97.8%;
--accent: 20.5 90.2% 48.2%;
--accent-foreground: 60 9.1% 97.8%;
}
:root[data-color-theme="custom"] {
--primary: var(--custom-primary, 142.1 76.2% 36.3%);
--primary-foreground: 355.7 100% 97.3%;
--accent: var(--custom-primary, 142.1 76.2% 36.3%);
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-interface-theme="frutiger"] {
--background: 0 0% 100%;
--foreground: 210 24% 8%;
--card: 0 0% 100%;
--card-foreground: 210 24% 8%;
--popover: 0 0% 100%;
--popover-foreground: 210 24% 8%;
--primary: 203 100% 18%;
--primary-foreground: 46 100% 91%;
--accent: 47 100% 50%;
--accent-foreground: 210 24% 8%;
--secondary: 47 100% 50%;
--secondary-foreground: 210 24% 8%;
--muted: 199 73% 91%;
--muted-foreground: 203 52% 24%;
--border: 203 45% 42%;
--input: 203 45% 42%;
--ring: 203 100% 18%;
}
:root[data-interface-theme="frutiger-aero"] {
--background: 190 76% 96%;
--foreground: 205 50% 12%;
--card: 0 0% 100%;
--card-foreground: 205 50% 12%;
--popover: 0 0% 100%;
--popover-foreground: 205 50% 12%;
--primary: 201 100% 37%;
--primary-foreground: 0 0% 100%;
--accent: 104 55% 55%;
--accent-foreground: 205 50% 12%;
--secondary: 104 55% 55%;
--secondary-foreground: 205 50% 12%;
--muted: 190 56% 90%;
--muted-foreground: 205 32% 32%;
--border: 195 48% 74%;
--input: 195 48% 74%;
--ring: 201 100% 37%;
}
:root[data-color-mode="dark"][data-color-theme="slate"],
:root.dark[data-color-theme="slate"] {
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
}
@media (prefers-color-scheme: dark) {
:root:not([data-color-mode="light"])[data-color-theme="slate"] {
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
}
}
}
@theme inline {
@@ -415,298 +129,48 @@
}
@layer utilities {
:root[data-interface-theme="shadcn"] .brand-background,
:root[data-interface-theme="minimal"] .brand-background {
display: none;
.dashboard-mobile-header {
min-height: var(--dashboard-mobile-header-height);
}
:root[data-interface-theme="frutiger"] .brand-background {
display: none;
}
:root[data-interface-theme="frutiger-aero"] .brand-background {
display: flex;
background:
radial-gradient(circle at 18% 22%, hsl(104 55% 55% / 0.35), transparent 32%),
radial-gradient(circle at 82% 18%, hsl(190 88% 66% / 0.42), transparent 30%),
linear-gradient(180deg, hsl(195 100% 94% / 0.95), hsl(0 0% 100% / 0.35));
}
:root[data-interface-theme="frutiger"] .dashboard-content-shell {
.dashboard-content-shell {
padding: 1rem;
padding-top: 4rem;
padding-top: calc(
var(--dashboard-mobile-header-height) + var(--dashboard-mobile-header-gap)
);
}
@media (min-width: 768px) {
:root[data-interface-theme="frutiger"] .dashboard-content-shell {
.dashboard-content-shell {
padding: 1.25rem;
padding-top: 1.25rem;
}
}
:root[data-interface-theme="frutiger"] .bg-dashboard {
background: hsl(var(--background));
}
:root[data-interface-theme="frutiger-aero"] .bg-dashboard {
background:
radial-gradient(circle at 78% 8%, hsl(104 55% 55% / 0.22), transparent 26rem),
linear-gradient(180deg, hsl(190 76% 96%) 0%, hsl(0 0% 100%) 72%);
}
:root[data-interface-theme="frutiger"] aside {
border-color: hsl(var(--primary));
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
:root[data-interface-theme="frutiger"] aside [class*="text-muted-foreground"] {
color: hsl(var(--primary-foreground) / 0.72);
}
:root[data-interface-theme="frutiger"] aside a {
color: hsl(var(--primary-foreground) / 0.86);
}
:root[data-interface-theme="frutiger"] aside a:hover,
:root[data-interface-theme="frutiger"] aside a[data-active="true"] {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
:root[data-interface-theme="frutiger"] aside button {
color: hsl(var(--primary-foreground) / 0.82);
}
:root[data-interface-theme="frutiger"] .dashboard-mobile-header {
border-color: hsl(var(--primary));
background-color: hsl(var(--accent));
color: hsl(var(--foreground));
backdrop-filter: none;
}
:root[data-interface-theme="frutiger"] .platform-header-surface {
border-color: hsl(var(--primary));
border-top: 0.5rem solid hsl(var(--primary));
background-color: hsl(var(--accent));
color: hsl(var(--foreground));
box-shadow: none;
}
:root[data-interface-theme="frutiger"] .platform-header-gradient {
display: none !important;
background: none !important;
}
:root[data-interface-theme="frutiger"] .platform-header-surface .text-primary,
:root[data-interface-theme="frutiger"] .platform-header-surface [class*="text-primary"],
:root[data-interface-theme="frutiger"] .dashboard-mobile-header .text-primary,
:root[data-interface-theme="frutiger"] .dashboard-mobile-header [class*="text-primary"] {
color: hsl(var(--foreground));
}
:root[data-interface-theme="frutiger"] .platform-header-surface .text-muted-foreground,
:root[data-interface-theme="frutiger"] .platform-header-surface [class*="text-muted-foreground"] {
color: hsl(var(--foreground) / 0.72);
}
:root[data-interface-theme="frutiger-aero"] aside {
border-color: hsl(190 88% 66% / 0.55);
background:
linear-gradient(180deg, hsl(201 100% 37% / 0.82), hsl(190 88% 45% / 0.72)),
hsl(201 100% 37% / 0.78);
color: hsl(var(--primary-foreground));
box-shadow: 0 18px 36px -20px hsl(201 100% 22% / 0.55);
backdrop-filter: blur(18px) saturate(1.35);
}
:root[data-interface-theme="frutiger-aero"] aside [class*="text-muted-foreground"] {
color: hsl(var(--primary-foreground) / 0.76);
}
:root[data-interface-theme="frutiger-aero"] aside a {
color: hsl(var(--primary-foreground) / 0.88);
}
:root[data-interface-theme="frutiger-aero"] aside a:hover,
:root[data-interface-theme="frutiger-aero"] aside a[data-active="true"] {
background-color: hsl(0 0% 100% / 0.92);
color: hsl(var(--primary));
}
:root[data-interface-theme="frutiger-aero"] .dashboard-mobile-header {
border-color: hsl(190 88% 66% / 0.55);
background-color: hsl(0 0% 100% / 0.78);
color: hsl(var(--foreground));
backdrop-filter: blur(18px) saturate(1.35);
}
:root[data-interface-theme="frutiger-aero"] .platform-header-surface {
border-color: hsl(190 88% 66% / 0.6);
background:
linear-gradient(180deg, hsl(0 0% 100% / 0.88), hsl(190 88% 92% / 0.72)),
hsl(0 0% 100% / 0.72);
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.9),
0 18px 36px -28px hsl(201 100% 37% / 0.6);
backdrop-filter: blur(16px) saturate(1.35);
}
:root[data-interface-theme="frutiger-aero"] .platform-header-gradient {
display: block;
background: radial-gradient(circle at 92% 10%, hsl(104 55% 55% / 0.45), transparent 34%),
linear-gradient(135deg, hsl(190 88% 66% / 0.28), transparent 48%);
opacity: 1;
}
:root[data-interface-theme="frutiger"] [data-slot="card"],
:root[data-interface-theme="frutiger"] [data-slot="dialog-content"],
:root[data-interface-theme="frutiger"] [data-slot="popover-content"],
:root[data-interface-theme="frutiger"] [data-slot="select-content"] {
border-color: hsl(var(--primary) / 0.45);
border-radius: 0;
background-color: hsl(var(--card));
box-shadow: none;
}
:root[data-interface-theme="frutiger"] [data-slot="card"] {
border-top: 0.35rem solid hsl(var(--accent));
}
:root[data-interface-theme="frutiger-aero"] [data-slot="card"] {
border-color: hsl(190 88% 66% / 0.45);
background-color: hsl(0 0% 100% / 0.78);
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.9),
0 16px 34px -30px hsl(201 100% 37% / 0.7);
backdrop-filter: blur(14px) saturate(1.25);
}
:root[data-interface-theme="frutiger"] [data-slot="button"],
:root[data-interface-theme="frutiger"] button,
:root[data-interface-theme="frutiger"] input,
:root[data-interface-theme="frutiger"] textarea,
:root[data-interface-theme="frutiger"] [role="combobox"] {
border-radius: 0;
}
:root[data-interface-theme="frutiger"] .button-hover:hover,
:root[data-interface-theme="frutiger"] .card-hover:hover {
transform: none;
box-shadow: none;
}
:root[data-interface-theme="frutiger"] [data-slot="card-header"],
:root[data-interface-theme="frutiger"] [data-slot="card-content"],
:root[data-interface-theme="frutiger"] [data-slot="card-footer"] {
padding-inline: 1rem;
}
:root[data-interface-theme="minimal"] [data-slot="card"] {
background-color: transparent;
border-color: transparent;
border-radius: 0;
border-top-color: hsl(var(--border));
box-shadow: none;
backdrop-filter: none;
overflow: visible;
}
:root[data-interface-theme="minimal"] [data-slot="card"] + [data-slot="card"],
:root[data-interface-theme="minimal"] .form-section + .form-section {
border-top: 1px solid hsl(var(--border));
padding-top: 1rem;
}
:root[data-interface-theme="minimal"] [data-slot="card-header"],
:root[data-interface-theme="minimal"] [data-slot="card-content"],
:root[data-interface-theme="minimal"] [data-slot="card-footer"] {
padding-inline: 0;
}
:root[data-interface-theme="minimal"] [data-slot="card-header"] {
padding-top: 0.75rem;
padding-bottom: 0.5rem;
}
:root[data-interface-theme="minimal"] [data-slot="card-content"] {
padding-bottom: 0.75rem;
}
:root[data-interface-theme="minimal"] .page-enter,
:root[data-interface-theme="minimal"] [class*="space-y-8"],
:root[data-interface-theme="minimal"] [class*="space-y-6"] {
row-gap: 1rem;
}
:root[data-interface-theme="minimal"]
[class*="space-y-8"]
> :not([hidden])
~ :not([hidden]),
:root[data-interface-theme="minimal"]
[class*="space-y-6"]
> :not([hidden])
~ :not([hidden]) {
margin-top: 1rem;
}
:root[data-interface-theme="minimal"] [class*="gap-6"] {
gap: 1rem;
}
:root[data-interface-theme="minimal"] .platform-header-surface {
background-color: transparent;
border-color: transparent;
box-shadow: none;
backdrop-filter: none;
overflow: visible;
}
:root[data-interface-theme="minimal"] .platform-header-content {
padding: 0;
}
:root[data-interface-theme="minimal"] .platform-header-gradient {
display: none;
}
:root[data-interface-theme="minimal"] .bg-dashboard {
background-color: hsl(var(--background));
}
:root[data-interface-theme="beenvoice"] .dashboard-content-shell {
padding: 0.75rem;
padding-top: 4rem;
}
@media (min-width: 768px) {
:root[data-interface-theme="beenvoice"] .dashboard-content-shell {
padding-top: 0.75rem;
}
}
:root[data-interface-theme="beenvoice"] .platform-header-content {
.platform-header-content {
padding: 1.25rem;
}
:root[data-interface-theme="beenvoice"] [data-slot="card"] {
[data-slot="card"],
[data-slot="dialog-content"],
[data-slot="alert-dialog-content"],
[data-slot="popover-content"] {
border-radius: var(--radius-lg);
}
:root[data-interface-theme="beenvoice"] [data-slot="card-header"] {
[data-slot="card-header"] {
padding: 1rem 1rem 0.75rem;
}
:root[data-interface-theme="beenvoice"] [data-slot="card-content"] {
[data-slot="card-content"] {
padding-inline: 1rem;
padding-bottom: 1rem;
}
:root[data-interface-theme="beenvoice"] [data-slot="card-footer"] {
[data-slot="card-footer"] {
padding: 1rem;
}
:root[data-interface-theme="editorial"] .brand-background {
opacity: 0.55;
}
.animate-blob {
animation: blob 7s infinite;
}
@@ -728,25 +192,6 @@
transform: translateY(-2px);
box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1);
}
:root[data-radius] .rounded-sm {
border-radius: var(--radius-sm);
}
:root[data-radius] .rounded,
:root[data-radius] .rounded-md {
border-radius: var(--radius-md);
}
:root[data-radius] .rounded-lg {
border-radius: var(--radius-lg);
}
:root[data-radius] .rounded-xl,
:root[data-radius] .rounded-2xl,
:root[data-radius] .rounded-3xl {
border-radius: var(--radius-xl);
}
}
@keyframes blob {
+2 -3
View File
@@ -8,6 +8,7 @@ import { useState } from "react";
import SuperJSON from "superjson";
import { createQueryClient } from "./query-client";
import { getAppUrl } from "~/lib/app-url";
import type { AppRouter } from "~/server/api/root";
let clientQueryClientSingleton: QueryClient | undefined = undefined;
@@ -72,7 +73,5 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
}
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.NEXT_PUBLIC_APP_URL) return process.env.NEXT_PUBLIC_APP_URL;
return `http://localhost:${process.env.PORT ?? 3000}`;
return getAppUrl();
}