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>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "~/lib/auth";
|
||||
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
|
||||
import { env } from "~/env";
|
||||
import { db } from "~/server/db";
|
||||
import { accounts, users } from "~/server/db/schema";
|
||||
@@ -161,6 +162,12 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
|
||||
const databaseSetupError = getDatabaseSetupErrorMessage(error);
|
||||
if (databaseSetupError) {
|
||||
return NextResponse.json({ error: databaseSetupError }, { status: 503 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,32 @@ import { Button } from "~/components/ui/button";
|
||||
import { Square, Clock } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function ActiveTimerWidget() {
|
||||
interface ActiveTimerWidgetProps {
|
||||
collapsed?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ActiveTimerWidget({
|
||||
collapsed = false,
|
||||
compact = false,
|
||||
}: ActiveTimerWidgetProps) {
|
||||
const utils = api.useUtils();
|
||||
const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(
|
||||
undefined,
|
||||
{
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: 60_000,
|
||||
},
|
||||
);
|
||||
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -56,9 +76,6 @@ export function ActiveTimerWidget() {
|
||||
}
|
||||
|
||||
void utils.timeEntries.getRunning.invalidate();
|
||||
void utils.timeEntries.getAll.invalidate();
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
@@ -69,64 +86,153 @@ export function ActiveTimerWidget() {
|
||||
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||
: null;
|
||||
|
||||
const description =
|
||||
running.description || (
|
||||
<span className="text-muted-foreground italic">No description</span>
|
||||
);
|
||||
|
||||
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 flex-col items-center gap-1">
|
||||
<Link
|
||||
href="/dashboard/time-clock"
|
||||
className="border-primary/30 bg-primary/5 flex items-center gap-2 rounded-md border px-2.5 py-1.5"
|
||||
>
|
||||
<span className="relative flex h-2 w-2 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 w-2 rounded-full" />
|
||||
</span>
|
||||
<span className="text-primary font-mono text-sm font-bold tabular-nums">
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</span>
|
||||
</Link>
|
||||
{renderStopButton()}
|
||||
</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="max-w-56 space-y-2 p-3">
|
||||
<p className="text-sm font-medium">
|
||||
{description}
|
||||
{running.client && (
|
||||
<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-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>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{running.description || (
|
||||
<span className="text-muted-foreground italic">No description</span>
|
||||
)}
|
||||
{running.client && (
|
||||
<span className="text-muted-foreground font-normal"> · {running.client.name}</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{invoiceLabel ? (
|
||||
<>
|
||||
Billing to{" "}
|
||||
<Link
|
||||
href={`/dashboard/invoices/${running.invoice!.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{invoiceLabel}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>No invoice selected — open time clock to assign</>
|
||||
)}
|
||||
{" · "}
|
||||
<Link href="/dashboard/time-clock" className="text-primary hover:underline">
|
||||
Time clock
|
||||
</Link>
|
||||
</p>
|
||||
<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{" "}
|
||||
<Link
|
||||
href={`/dashboard/invoices/${running.invoice!.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{invoiceLabel}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>No invoice selected — open time clock to assign</>
|
||||
)}
|
||||
{" · "}
|
||||
<Link href="/dashboard/time-clock" className="text-primary hover:underline">
|
||||
Time clock
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-primary font-mono text-2xl font-bold tabular-nums">
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</span>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/dashboard/time-clock">
|
||||
<Clock className="mr-1.5 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>
|
||||
<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 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 h-3.5 w-3.5" />
|
||||
Open
|
||||
</Link>
|
||||
</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>
|
||||
<div
|
||||
className="flex items-center space-x-1 text-xs"
|
||||
style={{
|
||||
color: isNeutral
|
||||
? "hsl(var(--muted-foreground))"
|
||||
: isPositive
|
||||
? "oklch(var(--chart-2))"
|
||||
: "oklch(var(--chart-3))",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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={cn(
|
||||
"flex items-center gap-1 text-xs font-medium",
|
||||
isNeutral
|
||||
? "text-muted-foreground"
|
||||
: isPositive
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-amber-600 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
<TrendIcon className="h-3 w-3" />
|
||||
<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%">
|
||||
<PieChart>
|
||||
<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,9 +87,8 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-48 w-full md:h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<ResponsiveChart height={256} className="h-48 md:h-64">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -145,11 +146,10 @@ export default function ExpensesPage() {
|
||||
.reduce((s, e) => s + e.amount, 0);
|
||||
|
||||
return (
|
||||
<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 +158,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">
|
||||
@@ -476,6 +475,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -24,7 +24,13 @@ import { notFound, useParams, useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { StatusBadge } from "~/components/data/status-badge";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
DashboardPage,
|
||||
dashboardGapClass,
|
||||
dashboardGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
@@ -224,11 +230,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<Button asChild variant="default" className="hover-lift">
|
||||
@@ -237,11 +242,11 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
</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 +536,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} />
|
||||
)}
|
||||
@@ -833,7 +838,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,23 +346,22 @@ 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">
|
||||
<Card>
|
||||
<PageTabsContent value="compose">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
@@ -387,10 +396,10 @@ export default function SendEmailPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</PageTabsContent>
|
||||
|
||||
<TabsContent value="preview" className="space-y-6">
|
||||
<Card>
|
||||
<PageTabsContent value="preview">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5" />
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { FileText, Plus, Upload } from "lucide-react";
|
||||
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
@@ -16,11 +17,10 @@ async function InvoicesTable() {
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
return (
|
||||
<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">
|
||||
@@ -40,13 +40,13 @@ export default async function InvoicesPage() {
|
||||
<span>Create Invoice</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
||||
<InvoicesTable />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
@@ -358,17 +359,16 @@ export default function RecurringInvoicesPage() {
|
||||
const isSubmitting = create.isPending || update.isPending;
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -529,6 +529,6 @@ export default function RecurringInvoicesPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
<DashboardUserProvider isAdmin={isAdmin} needsOnboarding={needsOnboarding}>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</DashboardUserProvider>
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Sparkles,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
type Step = "welcome" | "business" | "client" | "done";
|
||||
|
||||
export function OnboardingWizard() {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const { data: status, isLoading } = api.settings.getOnboardingStatus.useQuery();
|
||||
|
||||
const [step, setStep] = useState<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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && status && !status.completed && step === "welcome") {
|
||||
if (status.businessCount > 0 && status.clientCount > 0) {
|
||||
setStep("done");
|
||||
} else if (status.businessCount > 0) {
|
||||
setStep("client");
|
||||
}
|
||||
}
|
||||
}, [isLoading, status, step]);
|
||||
|
||||
function handleSkip() {
|
||||
completeOnboarding.mutate();
|
||||
}
|
||||
|
||||
function handleBusinessSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!businessName.trim()) {
|
||||
toast.error("Business name is required");
|
||||
return;
|
||||
}
|
||||
createBusiness.mutate({
|
||||
name: businessName.trim(),
|
||||
isDefault: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleClientSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!clientName.trim()) {
|
||||
toast.error("Client name is required");
|
||||
return;
|
||||
}
|
||||
createClient.mutate({ name: clientName.trim() });
|
||||
}
|
||||
|
||||
function handleFinish() {
|
||||
completeOnboarding.mutate();
|
||||
}
|
||||
|
||||
function handleCreateInvoice() {
|
||||
completeOnboarding.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
router.push("/dashboard/invoices/new");
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading || status?.completed) {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[60vh] max-w-lg items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-lg flex-col justify-center py-8">
|
||||
<div className="mb-6 flex items-center justify-center gap-2">
|
||||
<Sparkles className="text-primary h-5 w-5" />
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
{step === "welcome" && "Step 1 of 3"}
|
||||
{step === "business" && "Step 2 of 3"}
|
||||
{step === "client" && "Step 3 of 3"}
|
||||
{step === "done" && "All set"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{step === "welcome" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome to BeenVoice</CardTitle>
|
||||
<CardDescription>
|
||||
Let's set up the basics so you can send your first invoice.
|
||||
This only takes a minute.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<Building2 className="text-primary mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>Add the business you send invoices from</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Users className="text-primary mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>Add your first client to bill</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button className="flex-1" onClick={() => setStep("business")}>
|
||||
Get started
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSkip}
|
||||
disabled={completeOnboarding.isPending}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === "business" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your business</CardTitle>
|
||||
<CardDescription>
|
||||
This appears on invoices as the sender — name, logo, and contact
|
||||
details.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleBusinessSubmit} className="space-y-4">
|
||||
<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"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={createBusiness.isPending}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={handleSkip}>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === "client" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your first client</CardTitle>
|
||||
<CardDescription>
|
||||
Who are you billing? You can add more details later.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleClientSubmit} className="space-y-4">
|
||||
<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"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={createClient.isPending}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={handleSkip}>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === "done" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="text-primary h-5 w-5" />
|
||||
You're ready to go
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Your workspace is set up. Create an invoice or explore the
|
||||
dashboard.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button className="flex-1" onClick={handleFinish}>
|
||||
Go to dashboard
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1" onClick={handleCreateInvoice}>
|
||||
Create first invoice
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step !== "welcome" && step !== "done" && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-muted-foreground mt-4"
|
||||
onClick={() =>
|
||||
setStep(step === "client" ? "business" : "welcome")
|
||||
}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { OnboardingWizard } from "./_components/onboarding-wizard";
|
||||
|
||||
export default function OnboardingPage() {
|
||||
return (
|
||||
<DashboardPage>
|
||||
<OnboardingWizard />
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
+190
-308
@@ -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">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
<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'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
|
||||
</Link>
|
||||
</Button>
|
||||
</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>
|
||||
</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">
|
||||
{totalHours.toFixed(1)} hours logged
|
||||
</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>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover-lift flex-1"
|
||||
>
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.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`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Continue
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<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="flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentDraft.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Continue
|
||||
</Link>
|
||||
</Button>
|
||||
</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">
|
||||
<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">
|
||||
#{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">
|
||||
${invoice.totalAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{new Date(invoice.issueDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-sm font-medium">
|
||||
#{invoice.invoiceNumber}
|
||||
</p>
|
||||
<span className="shrink-0 font-mono text-sm font-medium tabular-nums">
|
||||
${invoice.totalAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{invoice.client?.name}
|
||||
</p>
|
||||
<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>
|
||||
<DashboardStats stats={stats} />
|
||||
<ChartsSection stats={stats} />
|
||||
<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>
|
||||
<RecentActivity recentInvoices={stats.recentInvoices} />
|
||||
</DashboardGrid>
|
||||
</DashboardPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,448 +646,49 @@ 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.
|
||||
<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
|
||||
</Label>
|
||||
<Select
|
||||
value={colorMode}
|
||||
onValueChange={(value) =>
|
||||
updateAppearance({
|
||||
colorMode: value as typeof colorMode,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{colorModes.map((modeOption) => (
|
||||
<SelectItem
|
||||
key={modeOption.value}
|
||||
value={modeOption.value}
|
||||
>
|
||||
{modeOption.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{
|
||||
colorModes.find(
|
||||
(modeOption) => modeOption.value === colorMode,
|
||||
)?.description
|
||||
}
|
||||
</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">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4" />
|
||||
Color Mode
|
||||
</Label>
|
||||
<Select
|
||||
value={colorMode}
|
||||
onValueChange={(value) =>
|
||||
updateAppearance({
|
||||
colorMode: value as typeof colorMode,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{colorModes.map((modeOption) => (
|
||||
<SelectItem
|
||||
key={modeOption.value}
|
||||
value={modeOption.value}
|
||||
>
|
||||
{modeOption.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{
|
||||
colorModes.find(
|
||||
(modeOption) => modeOption.value === colorMode,
|
||||
)?.description
|
||||
}
|
||||
</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>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</div>
|
||||
{appearanceUpdating && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-51
@@ -4,14 +4,7 @@ import { type Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
import {
|
||||
brand,
|
||||
defaultBodyFontPreference,
|
||||
defaultHeadingFontPreference,
|
||||
defaultInterfaceTheme,
|
||||
defaultRadiusPreference,
|
||||
defaultSidebarStyle,
|
||||
} from "~/lib/branding";
|
||||
import { brand } from "~/lib/branding";
|
||||
|
||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||
import { BrandBackground } from "~/components/layout/brand-background";
|
||||
@@ -34,23 +27,6 @@ const playfair = localFont({
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const frutiger = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../../public/fonts/frutiger/Frutiger.ttf",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../../public/fonts/frutiger/Frutiger_bold.ttf",
|
||||
weight: "700",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
variable: "--font-frutiger",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = localFont({
|
||||
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
|
||||
variable: "--font-geist-mono",
|
||||
@@ -64,14 +40,8 @@ export default function RootLayout({
|
||||
<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 +49,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 {}
|
||||
`,
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user