Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fb61ff4dd | ||
|
|
c53f2e6c4d |
+1
-15
@@ -85,27 +85,13 @@ POSTGRES_DB=postgres
|
|||||||
# White-label defaults (optional)
|
# White-label defaults (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Baked in at Docker build. After first deploy, admins can override many of
|
# Baked in at Docker build. After first deploy, admins can override many of
|
||||||
# these from Settings → Appearance in the dashboard.
|
# Optional white-label defaults (build-time). Users choose light/dark in Settings.
|
||||||
|
|
||||||
NEXT_PUBLIC_BRAND_NAME=beenvoice
|
NEXT_PUBLIC_BRAND_NAME=beenvoice
|
||||||
NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses
|
NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses
|
||||||
NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
|
NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
|
||||||
NEXT_PUBLIC_BRAND_ICON=$
|
NEXT_PUBLIC_BRAND_ICON=$
|
||||||
|
|
||||||
# Interface theme: beenvoice | frutiger | frutiger-aero | shadcn | minimal | editorial
|
|
||||||
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME=beenvoice
|
|
||||||
|
|
||||||
# Font prefs: brand | frutiger | platform | inter | serif
|
|
||||||
NEXT_PUBLIC_DEFAULT_FONT=brand
|
|
||||||
NEXT_PUBLIC_DEFAULT_BODY_FONT=brand
|
|
||||||
NEXT_PUBLIC_DEFAULT_HEADING_FONT=brand
|
|
||||||
|
|
||||||
# Corner radius: none | sm | md | lg | xl
|
|
||||||
NEXT_PUBLIC_DEFAULT_RADIUS=xl
|
|
||||||
|
|
||||||
# Sidebar chrome: floating | docked
|
|
||||||
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE=floating
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Email — Resend (optional)
|
# Email — Resend (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ Start Postgres (dev compose exposes port 5432):
|
|||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After a fresh volume (`docker compose down -v`), Postgres starts empty — you must apply schema before registering or signing in.
|
||||||
|
|
||||||
Apply schema (pick one):
|
Apply schema (pick one):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "colorTheme";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "customColor";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "interfaceTheme";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "fontPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "bodyFontPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "headingFontPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "radiusPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "sidebarStyle";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandName";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandTagline";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandLogoText";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandIcon";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "colorTheme";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "customColor";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "theme";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "interfaceTheme";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "bodyFontPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "headingFontPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "radiusPreference";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "sidebarStyle";
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE "beenvoice_user" ADD COLUMN IF NOT EXISTS "onboardingCompletedAt" timestamp;
|
||||||
|
|
||||||
|
-- Users who already have a business are treated as onboarded
|
||||||
|
UPDATE "beenvoice_user" u
|
||||||
|
SET "onboardingCompletedAt" = COALESCE(u."onboardingCompletedAt", NOW())
|
||||||
|
WHERE u."onboardingCompletedAt" IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "beenvoice_business" b
|
||||||
|
WHERE b."createdById" = u."id"
|
||||||
|
);
|
||||||
@@ -120,6 +120,20 @@
|
|||||||
"when": 1781500000000,
|
"when": 1781500000000,
|
||||||
"tag": "0016_fix_send_reminder_at_column",
|
"tag": "0016_fix_send_reminder_at_column",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781600000000,
|
||||||
|
"tag": "0017_drop_theme_engine_columns",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781700000000,
|
||||||
|
"tag": "0018_user_onboarding",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import drizzle from "eslint-plugin-drizzle";
|
|||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [".next"],
|
ignores: [".next", "scripts/**"],
|
||||||
},
|
},
|
||||||
...nextCoreWebVitals,
|
...nextCoreWebVitals,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { db } from "~/server/db";
|
|||||||
import { users } from "~/server/db/schema";
|
import { users } from "~/server/db/schema";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { env } from "~/env";
|
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 { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Send password reset email using Resend
|
// Send password reset email using Resend
|
||||||
try {
|
try {
|
||||||
const resend = new Resend(env.RESEND_API_KEY);
|
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({
|
const emailTemplate = generatePasswordResetEmailTemplate({
|
||||||
userEmail: email,
|
userEmail: email,
|
||||||
@@ -82,8 +84,10 @@ export async function POST(request: NextRequest) {
|
|||||||
expiryHours: 24,
|
expiryHours: 24,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
|
||||||
|
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: "beenvoice <noreply@beenvoice.com>",
|
from: `beenvoice <noreply@${fromDomain}>`,
|
||||||
to: email,
|
to: email,
|
||||||
subject: emailTemplate.subject,
|
subject: emailTemplate.subject,
|
||||||
html: emailTemplate.html,
|
html: emailTemplate.html,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { eq } from "drizzle-orm";
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "~/lib/auth";
|
import { auth } from "~/lib/auth";
|
||||||
|
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
|
||||||
|
import { resolveNewUserRole } from "~/lib/first-admin";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { accounts, users } from "~/server/db/schema";
|
import { accounts, users } from "~/server/db/schema";
|
||||||
@@ -118,12 +120,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
const role = await resolveNewUserRole(tx);
|
||||||
|
|
||||||
const [user] = await tx
|
const [user] = await tx
|
||||||
.insert(users)
|
.insert(users)
|
||||||
.values({
|
.values({
|
||||||
name: `${firstName} ${lastName}`,
|
name: `${firstName} ${lastName}`,
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
|
role,
|
||||||
})
|
})
|
||||||
.returning({ id: users.id });
|
.returning({ id: users.id });
|
||||||
|
|
||||||
@@ -161,6 +166,12 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Registration error:", error);
|
console.error("Registration error:", error);
|
||||||
|
|
||||||
|
const databaseSetupError = getDatabaseSetupErrorMessage(error);
|
||||||
|
if (databaseSetupError) {
|
||||||
|
return NextResponse.json({ error: databaseSetupError }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{ error: "Internal server error" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z, type ZodType } from "zod";
|
|||||||
|
|
||||||
import { createCaller } from "~/server/api/root";
|
import { createCaller } from "~/server/api/root";
|
||||||
import { createTRPCContext } from "~/server/api/trpc";
|
import { createTRPCContext } from "~/server/api/trpc";
|
||||||
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -856,7 +857,7 @@ const tools = {
|
|||||||
schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }),
|
schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }),
|
||||||
handler: async (input, caller) => {
|
handler: async (input, caller) => {
|
||||||
const result = await caller.invoices.generatePublicToken(input);
|
const result = await caller.invoices.generatePublicToken(input);
|
||||||
const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
const base = getAppUrl();
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
webUrl: `${base}/i/${result.token}`,
|
webUrl: `${base}/i/${result.token}`,
|
||||||
|
|||||||
@@ -1,213 +1,5 @@
|
|||||||
"use client";
|
import { RegisterForm } from "./register-form";
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
return (
|
return <RegisterForm />;
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
|
||||||
<RegisterForm />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ArrowRight, Lock, Mail, User } from "lucide-react";
|
||||||
|
import {
|
||||||
|
AuthCard,
|
||||||
|
AuthCardHeader,
|
||||||
|
AuthPageShell,
|
||||||
|
} from "~/components/auth/auth-page-shell";
|
||||||
|
import { LegalAgreementNotice } from "~/components/legal/legal-links";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
function formatAuthError(message: string | undefined, fallback: string): string {
|
||||||
|
if (!message || message === "Required") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleRegister(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const trimmedFirstName = firstName.trim();
|
||||||
|
const trimmedLastName = lastName.trim();
|
||||||
|
const trimmedEmail = email.trim();
|
||||||
|
|
||||||
|
if (!trimmedFirstName || !trimmedLastName || !trimmedEmail) {
|
||||||
|
toast.error("Please enter your first name, last name, and email.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
toast.error("Password must be at least 8 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
firstName: trimmedFirstName,
|
||||||
|
lastName: trimmedLastName,
|
||||||
|
email: trimmedEmail,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let data: { error?: string; signInRequired?: boolean } = {};
|
||||||
|
try {
|
||||||
|
data = (await res.json()) as typeof data;
|
||||||
|
} catch {
|
||||||
|
toast.error("Registration failed. Please try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error(
|
||||||
|
formatAuthError(data.error, "Registration failed. Please check the form."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.signInRequired) {
|
||||||
|
toast.success("Account created! Please sign in.");
|
||||||
|
router.push("/auth/signin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Account created successfully!");
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error("Registration failed. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthPageShell>
|
||||||
|
<AuthCard>
|
||||||
|
<AuthCardHeader
|
||||||
|
title="Create your account"
|
||||||
|
description="Get started with your workspace"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form onSubmit={handleRegister} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="firstName">First name</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="given-name"
|
||||||
|
className="h-11 pl-10"
|
||||||
|
placeholder="John"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lastName">Last name</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="family-name"
|
||||||
|
className="h-11 pl-10"
|
||||||
|
placeholder="Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
className="h-11 pl-10"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="h-11 pl-10"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">At least 8 characters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="h-11 w-full" disabled={loading}>
|
||||||
|
{loading ? "Creating account…" : "Create account"}
|
||||||
|
{!loading && <ArrowRight className="ml-2 h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/auth/signin"
|
||||||
|
className="text-foreground font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<LegalAgreementNotice action="creating an account" className="mt-5" />
|
||||||
|
</AuthCard>
|
||||||
|
</AuthPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,13 +7,37 @@ import { Card, CardContent } from "~/components/ui/card";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Square, Clock } from "lucide-react";
|
import { Square, Clock } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
|
import {
|
||||||
|
describeClockOutOutcome,
|
||||||
|
formatElapsedSeconds,
|
||||||
|
formatRunningTimerLabel,
|
||||||
|
} from "~/lib/time-clock";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "~/components/ui/tooltip";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
export function ActiveTimerWidget() {
|
interface ActiveTimerWidgetProps {
|
||||||
|
collapsed?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveTimerWidget({
|
||||||
|
collapsed = false,
|
||||||
|
compact = false,
|
||||||
|
}: ActiveTimerWidgetProps) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(undefined, {
|
const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(
|
||||||
refetchInterval: 30_000,
|
undefined,
|
||||||
});
|
{
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -56,9 +80,6 @@ export function ActiveTimerWidget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void utils.timeEntries.getRunning.invalidate();
|
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),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
@@ -69,24 +90,119 @@ export function ActiveTimerWidget() {
|
|||||||
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
const description = formatRunningTimerLabel(running.description);
|
||||||
<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">
|
const renderStopButton = (className?: string) => (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => clockOut.mutate({})}
|
||||||
|
disabled={clockOut.isPending}
|
||||||
|
className={cn(compact && "h-8 px-2", className)}
|
||||||
|
>
|
||||||
|
<Square className={cn("h-3.5 w-3.5", !compact && "mr-1.5")} />
|
||||||
|
{!compact && (clockOut.isPending ? "Stopping…" : "Stop")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="ml-auto flex min-w-0 items-center gap-1.5">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/time-clock"
|
||||||
|
className="border-primary/30 bg-primary/5 flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1"
|
||||||
|
>
|
||||||
|
<span className="relative flex h-2 w-2 shrink-0">
|
||||||
|
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||||
|
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" />
|
||||||
|
</span>
|
||||||
|
<span className="text-primary truncate font-mono text-sm font-bold tabular-nums">
|
||||||
|
{formatElapsedSeconds(elapsed)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
{renderStopButton("shrink-0")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/time-clock"
|
||||||
|
className="border-primary/30 bg-primary/5 relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors hover:bg-primary/10"
|
||||||
|
>
|
||||||
|
<Clock className="text-primary h-5 w-5" />
|
||||||
|
<span className="absolute top-1 right-1 flex h-2 w-2">
|
||||||
|
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||||
|
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
className="bg-popover text-popover-foreground border-border max-w-56 space-y-2 border p-3 text-sm [&>svg]:bg-popover [&>svg]:fill-popover"
|
||||||
|
>
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{running.description || (
|
{description}
|
||||||
<span className="text-muted-foreground italic">No description</span>
|
|
||||||
)}
|
|
||||||
{running.client && (
|
{running.client && (
|
||||||
<span className="text-muted-foreground font-normal"> · {running.client.name}</span>
|
<span className="text-muted-foreground font-normal">
|
||||||
|
{" "}
|
||||||
|
· {running.client.name}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-primary font-mono text-lg font-bold tabular-nums">
|
||||||
|
{formatElapsedSeconds(elapsed)}
|
||||||
|
</p>
|
||||||
|
{invoiceLabel ? (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Billing to{" "}
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/invoices/${running.invoice!.id}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{invoiceLabel}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-xs">No invoice selected</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button variant="outline" size="sm" asChild className="h-8 flex-1">
|
||||||
|
<Link href="/dashboard/time-clock">Open</Link>
|
||||||
|
</Button>
|
||||||
|
{renderStopButton()}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-primary/30 bg-primary/5">
|
||||||
|
<CardContent className="flex flex-col gap-3 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="relative mt-1 flex h-2.5 w-2.5 flex-shrink-0">
|
||||||
|
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||||
|
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm leading-snug font-medium">
|
||||||
|
{description}
|
||||||
|
{running.client && (
|
||||||
|
<span className="text-muted-foreground font-normal">
|
||||||
|
{" "}
|
||||||
|
· {running.client.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs leading-snug">
|
||||||
{invoiceLabel ? (
|
{invoiceLabel ? (
|
||||||
<>
|
<>
|
||||||
Billing to{" "}
|
Billing to{" "}
|
||||||
@@ -106,27 +222,21 @@ export function ActiveTimerWidget() {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span className="text-primary font-mono text-2xl font-bold tabular-nums">
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<span className="text-primary text-center font-mono text-xl font-bold tabular-nums">
|
||||||
{formatElapsedSeconds(elapsed)}
|
{formatElapsedSeconds(elapsed)}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex w-full flex-col gap-1.5">
|
||||||
<div className="flex gap-2">
|
<Button variant="outline" size="sm" asChild className="h-8 w-full">
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link href="/dashboard/time-clock">
|
<Link href="/dashboard/time-clock">
|
||||||
<Clock className="mr-1.5 h-3.5 w-3.5" />
|
<Clock className="mr-1 h-3.5 w-3.5" />
|
||||||
Open
|
Open
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{renderStopButton("w-full")}
|
||||||
variant="destructive"
|
</div>
|
||||||
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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} 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";
|
type IconName = "DollarSign" | "Clock" | "Users" | "TrendingDown";
|
||||||
|
|
||||||
@@ -51,41 +58,36 @@ export function AnimatedStatsCard({
|
|||||||
const isPositive = trend === "up";
|
const isPositive = trend === "up";
|
||||||
const isNeutral = trend === "neutral";
|
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 delay;
|
||||||
void isCurrency;
|
void isCurrency;
|
||||||
void numericValue;
|
void numericValue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
|
||||||
<div className="flex items-center justify-between space-y-0 pb-2">
|
<CardTitle className="text-muted-foreground flex items-center gap-2 text-sm font-medium">
|
||||||
<div className="flex items-center space-x-2">
|
<Icon className="h-4 w-4" />
|
||||||
<Icon className="text-muted-foreground h-5 w-5" />
|
{title}
|
||||||
<p className="text-muted-foreground text-sm font-medium">{title}</p>
|
</CardTitle>
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center space-x-1 text-xs"
|
className={cn(
|
||||||
style={{
|
"flex items-center gap-1 text-xs font-medium",
|
||||||
color: isNeutral
|
isNeutral
|
||||||
? "hsl(var(--muted-foreground))"
|
? "text-muted-foreground"
|
||||||
: isPositive
|
: isPositive
|
||||||
? "oklch(var(--chart-2))"
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
: "oklch(var(--chart-3))",
|
: "text-amber-600 dark:text-amber-400",
|
||||||
}}
|
)}
|
||||||
>
|
>
|
||||||
<TrendIcon className="h-3 w-3" />
|
<TrendIcon className="h-3 w-3" />
|
||||||
<span>{change}</span>
|
<span className="font-mono tabular-nums">{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>
|
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
"use client";
|
"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 { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
|
||||||
|
|
||||||
interface Invoice {
|
export interface StatusChartDatum {
|
||||||
id: string;
|
|
||||||
totalAmount: number;
|
|
||||||
status: string;
|
status: string;
|
||||||
dueDate: Date | string;
|
name: string;
|
||||||
|
count: number;
|
||||||
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InvoiceStatusChartProps {
|
interface InvoiceStatusChartProps {
|
||||||
invoices: Invoice[];
|
data: StatusChartDatum[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
const STATUS_COLORS = {
|
||||||
@@ -47,52 +46,26 @@ function StatusTooltip({
|
|||||||
return (
|
return (
|
||||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||||
<p className="font-medium">{data.name}</p>
|
<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" : ""}
|
{data.count} invoice{data.count !== 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">{formatChartCurrency(data.value)}</p>
|
<p className="font-mono text-sm tabular-nums">
|
||||||
|
{formatChartCurrency(data.value)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
export function InvoiceStatusChart({ data }: 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
|
|
||||||
const { prefersReducedMotion, animationSpeedMultiplier } =
|
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||||
useAnimationPreferences();
|
useAnimationPreferences();
|
||||||
const pieAnimationDuration = Math.round(
|
const pieAnimationDuration = Math.round(
|
||||||
600 / (animationSpeedMultiplier || 1),
|
600 / (animationSpeedMultiplier || 1),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (chartData.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -109,11 +82,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="h-48 w-full">
|
<ResponsiveChart height={192} className="h-48">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={chartData}
|
data={data}
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={40}
|
innerRadius={40}
|
||||||
@@ -124,7 +96,7 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
|||||||
animationDuration={pieAnimationDuration}
|
animationDuration={pieAnimationDuration}
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
>
|
>
|
||||||
{chartData.map((entry, index) => (
|
{data.map((entry, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={`cell-${index}`}
|
key={`cell-${index}`}
|
||||||
fill={
|
fill={
|
||||||
@@ -135,12 +107,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
|||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip content={<StatusTooltip />} />
|
<Tooltip content={<StatusTooltip />} />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveChart>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{chartData.map((item) => (
|
{data.map((item) => (
|
||||||
<div key={item.status} className="flex items-center justify-between">
|
<div key={item.status} className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div
|
<div
|
||||||
@@ -153,8 +123,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
|||||||
<span className="text-sm font-medium">{item.name}</span>
|
<span className="text-sm font-medium">{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-medium">{item.count}</p>
|
<p className="font-mono text-sm font-medium tabular-nums">
|
||||||
<p className="text-muted-foreground text-xs">
|
{item.count}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||||
{formatChartCurrency(item.value)}
|
{formatChartCurrency(item.value)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,25 +3,25 @@
|
|||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { ResponsiveChart } from "~/components/charts/responsive-chart";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
|
||||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||||
|
|
||||||
interface Invoice {
|
export interface MonthlyMetricsChartDatum {
|
||||||
id: string;
|
month: string;
|
||||||
totalAmount: number;
|
monthLabel: string;
|
||||||
issueDate: Date | string;
|
totalInvoices: number;
|
||||||
status: string;
|
paidInvoices: number;
|
||||||
dueDate: Date | string;
|
pendingInvoices: number;
|
||||||
|
overdueInvoices: number;
|
||||||
|
draftInvoices: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MonthlyMetricsChartProps {
|
interface MonthlyMetricsChartProps {
|
||||||
invoices: Invoice[];
|
data: MonthlyMetricsChartDatum[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function MonthlyMetricsTooltip({
|
function MonthlyMetricsTooltip({
|
||||||
@@ -31,28 +31,30 @@ function MonthlyMetricsTooltip({
|
|||||||
}: {
|
}: {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
payload?: Array<{
|
payload?: Array<{
|
||||||
payload: {
|
payload: MonthlyMetricsChartDatum;
|
||||||
paidInvoices: number;
|
|
||||||
pendingInvoices: number;
|
|
||||||
overdueInvoices: number;
|
|
||||||
draftInvoices: number;
|
|
||||||
totalInvoices: number;
|
|
||||||
};
|
|
||||||
}>;
|
}>;
|
||||||
label?: string;
|
label?: string;
|
||||||
}) {
|
}) {
|
||||||
if (active && payload?.length) {
|
if (active && payload?.length) {
|
||||||
const data = payload[0]!.payload;
|
const chartDatum = payload[0]!.payload;
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||||
<p className="font-medium">{label}</p>
|
<p className="font-medium">{label}</p>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
|
<p className="text-primary font-medium font-mono tabular-nums">
|
||||||
<p className="text-primary/80">Pending: {data.pendingInvoices}</p>
|
Paid: {chartDatum.paidInvoices}
|
||||||
<p className="text-destructive">Overdue: {data.overdueInvoices}</p>
|
</p>
|
||||||
<p className="text-muted-foreground">Draft: {data.draftInvoices}</p>
|
<p className="text-primary/80 font-mono tabular-nums">
|
||||||
<p className="text-foreground border-t pt-1 font-medium">
|
Pending: {chartDatum.pendingInvoices}
|
||||||
Total: {data.totalInvoices}
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,78 +63,14 @@ function MonthlyMetricsTooltip({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
export function MonthlyMetricsChart({ data }: 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
|
|
||||||
const { prefersReducedMotion, animationSpeedMultiplier } =
|
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||||
useAnimationPreferences();
|
useAnimationPreferences();
|
||||||
const barAnimationDuration = Math.round(
|
const barAnimationDuration = Math.round(
|
||||||
500 / (animationSpeedMultiplier || 1),
|
500 / (animationSpeedMultiplier || 1),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (chartData.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -149,9 +87,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="h-48 w-full">
|
<ResponsiveChart height={192} className="h-48">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<BarChart data={data}>
|
||||||
<BarChart data={chartData}>
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="monthLabel"
|
dataKey="monthLabel"
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
@@ -161,7 +98,11 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
|||||||
<YAxis
|
<YAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
tick={{
|
||||||
|
fontSize: 12,
|
||||||
|
fill: "var(--muted-foreground)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<MonthlyMetricsTooltip />} />
|
<Tooltip content={<MonthlyMetricsTooltip />} />
|
||||||
<Bar
|
<Bar
|
||||||
@@ -202,10 +143,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
|||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveChart>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import { ResponsiveChart } from "~/components/charts/responsive-chart";
|
||||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||||
|
|
||||||
interface RevenueChartProps {
|
interface RevenueChartProps {
|
||||||
@@ -41,7 +41,10 @@ const CustomTooltip = ({
|
|||||||
return (
|
return (
|
||||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||||
<p className="font-medium">{label}</p>
|
<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)}
|
Revenue: {formatCurrency(data.revenue)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
@@ -84,8 +87,7 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-48 w-full md:h-64">
|
<ResponsiveChart height={256} className="h-48 md:h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={chartData}>
|
<AreaChart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
@@ -110,7 +112,11 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
|||||||
<YAxis
|
<YAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={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}
|
tickFormatter={formatCurrency}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
@@ -127,7 +133,6 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
|||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveChart>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
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 { HydrateClient } from "~/trpc/server";
|
||||||
import { AdministrationContent } from "./_components/administration-content";
|
import { AdministrationContent } from "./_components/administration-content";
|
||||||
|
|
||||||
export default async function AdministrationPage() {
|
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 (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Administration"
|
title="Administration"
|
||||||
description="Manage account access and platform administration"
|
description="Manage account access and platform administration"
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
@@ -18,6 +36,6 @@ export default async function AdministrationPage() {
|
|||||||
<AdministrationContent />
|
<AdministrationContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { api } from "~/trpc/server";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
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 { Separator } from "~/components/ui/separator";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
@@ -43,11 +49,10 @@ export default async function BusinessDetailPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
|
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
|
||||||
description="View business details and information"
|
description="View business details and information"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" className="shadow-sm">
|
<Button asChild variant="outline" className="shadow-sm">
|
||||||
<Link href="/dashboard/entities?tab=businesses">
|
<Link href="/dashboard/entities?tab=businesses">
|
||||||
@@ -61,9 +66,9 @@ export default async function BusinessDetailPage({
|
|||||||
<span>Edit Business</span>
|
<span>Edit Business</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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 */}
|
{/* Business Information Card */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
@@ -265,7 +270,7 @@ export default async function BusinessDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings & Actions Card */}
|
{/* Settings & Actions Card */}
|
||||||
<div className="space-y-6">
|
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -323,6 +328,6 @@ export default async function BusinessDetailPage({
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
||||||
import { Building, Pencil, Trash2, ExternalLink } from "lucide-react";
|
import { Building, Pencil, Trash2, ExternalLink, Plus } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -208,6 +208,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
data={searchableBusinesses}
|
data={searchableBusinesses}
|
||||||
searchKey="searchValue"
|
searchKey="searchValue"
|
||||||
searchPlaceholder="Search by name or nickname..."
|
searchPlaceholder="Search by name or nickname..."
|
||||||
|
emptyTitle="Create your first business"
|
||||||
|
emptyDescription="Set up a business profile for invoices, branding, and tax details."
|
||||||
|
emptyIcon={<Building className="h-6 w-6" />}
|
||||||
|
emptyAction={
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/dashboard/businesses/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add business
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { api } from "~/trpc/server";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
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 Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Edit,
|
Edit,
|
||||||
@@ -57,11 +63,10 @@ export default async function ClientDetailPage({
|
|||||||
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
|
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title={client.name}
|
title={client.name}
|
||||||
description="View client details and information"
|
description="View client details and information"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" className="shadow-sm">
|
<Button asChild variant="outline" className="shadow-sm">
|
||||||
<Link href="/dashboard/entities?tab=clients">
|
<Link href="/dashboard/entities?tab=clients">
|
||||||
@@ -75,9 +80,9 @@ export default async function ClientDetailPage({
|
|||||||
<span>Edit Client</span>
|
<span>Edit Client</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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 */}
|
{/* Client Information Card */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
@@ -173,7 +178,7 @@ export default async function ClientDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Card */}
|
{/* Stats Card */}
|
||||||
<div className="space-y-6">
|
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -275,6 +280,6 @@ export default async function ClientDetailPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
||||||
import { UserPlus, Pencil, Trash2 } from "lucide-react";
|
import { UserPlus, Pencil, Trash2, Plus, Users } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -179,6 +179,17 @@ export function ClientsDataTable({
|
|||||||
data={clients}
|
data={clients}
|
||||||
searchKey="name"
|
searchKey="name"
|
||||||
searchPlaceholder="Search clients..."
|
searchPlaceholder="Search clients..."
|
||||||
|
emptyTitle="Create your first client"
|
||||||
|
emptyDescription="Add clients to bill them and keep contact details in one place."
|
||||||
|
emptyIcon={<Users className="h-6 w-6" />}
|
||||||
|
emptyAction={
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/dashboard/clients/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add client
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,32 @@
|
|||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
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 { Button } from "~/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import { ClientsDataTable } from "../../clients/_components/clients-data-table";
|
||||||
import { ClientsTable } from "../../clients/_components/clients-table";
|
import { BusinessesDataTable } from "../../businesses/_components/businesses-data-table";
|
||||||
import { BusinessesTable } from "../../businesses/_components/businesses-table";
|
import type { RouterOutputs } from "~/trpc/react";
|
||||||
|
|
||||||
type EntityTab = "clients" | "businesses";
|
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 router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const tab: EntityTab =
|
const tab: EntityTab =
|
||||||
@@ -27,11 +44,10 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
|
|||||||
const addLabel = tab === "clients" ? "Add client" : "Add business";
|
const addLabel = tab === "clients" ? "Add client" : "Add business";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Entities"
|
title="Entities"
|
||||||
description="Clients you bill and businesses you send from"
|
description="Clients you bill and businesses you send from"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
<Link href={addHref}>
|
<Link href={addHref}>
|
||||||
@@ -39,22 +55,24 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
|
|||||||
<span>{addLabel}</span>
|
<span>{addLabel}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<Tabs value={tab} onValueChange={handleTabChange}>
|
<PageTabs value={tab} onValueChange={handleTabChange}>
|
||||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
<PageTabsList>
|
||||||
<TabsTrigger value="clients">Clients</TabsTrigger>
|
<PageTabsTrigger value="clients">Clients</PageTabsTrigger>
|
||||||
<TabsTrigger value="businesses">Businesses</TabsTrigger>
|
<PageTabsTrigger value="businesses">Businesses</PageTabsTrigger>
|
||||||
</TabsList>
|
</PageTabsList>
|
||||||
|
|
||||||
<TabsContent value="clients" className="mt-6">
|
<PageTabsContent value="clients">
|
||||||
<ClientsTable />
|
{tab === "clients" ? <ClientsDataTable clients={clients} /> : null}
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
<TabsContent value="businesses" className="mt-6">
|
<PageTabsContent value="businesses">
|
||||||
<BusinessesTable />
|
{tab === "businesses" ? (
|
||||||
</TabsContent>
|
<BusinessesDataTable businesses={businesses} />
|
||||||
</Tabs>
|
) : null}
|
||||||
</div>
|
</PageTabsContent>
|
||||||
|
</PageTabs>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Suspense } from "react";
|
import { api } from "~/trpc/server";
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
import { api, HydrateClient } from "~/trpc/server";
|
|
||||||
import { EntitiesView } from "./_components/entities-view";
|
import { EntitiesView } from "./_components/entities-view";
|
||||||
|
|
||||||
export default async function EntitiesPage({
|
export default async function EntitiesPage({
|
||||||
@@ -11,16 +10,18 @@ export default async function EntitiesPage({
|
|||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const initialTab = params.tab === "businesses" ? "businesses" : "clients";
|
const initialTab = params.tab === "businesses" ? "businesses" : "clients";
|
||||||
|
|
||||||
void api.clients.getAll.prefetch();
|
const [clients, businesses] = await Promise.all([
|
||||||
void api.businesses.getAll.prefetch();
|
api.clients.getAll(),
|
||||||
|
api.businesses.getAll(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<HydrateClient>
|
<EntitiesView
|
||||||
<Suspense fallback={<DataTableSkeleton columns={5} rows={8} />}>
|
initialTab={initialTab}
|
||||||
<EntitiesView initialTab={initialTab} />
|
clients={clients}
|
||||||
</Suspense>
|
businesses={businesses}
|
||||||
</HydrateClient>
|
/>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
||||||
|
import { EmptyState } from "~/components/layout/page-layout";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
@@ -145,11 +147,10 @@ export default function ExpensesPage() {
|
|||||||
.reduce((s, e) => s + e.amount, 0);
|
.reduce((s, e) => s + e.amount, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-6">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Expenses"
|
title="Expenses"
|
||||||
description="Track billable and non-billable expenses"
|
description="Track billable and non-billable expenses"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
@@ -158,10 +159,9 @@ export default function ExpensesPage() {
|
|||||||
>
|
>
|
||||||
<Plus className="mr-2 h-5 w-5" /> Add Expense
|
<Plus className="mr-2 h-5 w-5" /> Add Expense
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
{/* Summary cards */}
|
<div className={dashboardStatGridClass}>
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||||
@@ -215,12 +215,17 @@ export default function ExpensesPage() {
|
|||||||
Loading…
|
Loading…
|
||||||
</div>
|
</div>
|
||||||
) : expenses.length === 0 ? (
|
) : expenses.length === 0 ? (
|
||||||
<div className="p-8 text-center">
|
<EmptyState
|
||||||
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
icon={<Receipt className="h-6 w-6" />}
|
||||||
<p className="text-muted-foreground text-sm">
|
title="Create your first expense"
|
||||||
No expenses yet. Add your first expense.
|
description="Track billable costs, reimbursements, and tax-deductible spending."
|
||||||
</p>
|
action={
|
||||||
</div>
|
<Button onClick={handleOpen}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add expense
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{expenses.map((expense) => (
|
{expenses.map((expense) => (
|
||||||
@@ -476,6 +481,6 @@ export default function ExpensesPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
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() {
|
export function InvoiceDetailsSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-24">
|
<DashboardPage className="pb-24">
|
||||||
{/* Header */}
|
<DashboardPageHeader
|
||||||
<PageHeader
|
|
||||||
title="Loading..."
|
title="Loading..."
|
||||||
description="View and manage invoice information"
|
description="View and manage invoice information"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Skeleton className="h-10 w-10 sm:w-32" />
|
<Skeleton className="h-10 w-10 sm:w-32" />
|
||||||
<Skeleton className="h-10 w-24" />
|
<Skeleton className="h-10 w-24" />
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
{/* Content */}
|
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
|
||||||
{/* Left Column */}
|
|
||||||
<div className="space-y-6 lg:col-span-2">
|
|
||||||
{/* Invoice Header Skeleton */}
|
{/* Invoice Header Skeleton */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4 sm:p-6">
|
<CardContent className="p-4 sm:p-6">
|
||||||
@@ -155,7 +157,7 @@ export function InvoiceDetailsSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||||
<Card className="lg:sticky lg:top-6">
|
<Card className="lg:sticky lg:top-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -172,6 +174,6 @@ export function InvoiceDetailsSkeleton() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { ExternalLink } from "lucide-react";
|
|
||||||
|
|
||||||
interface InvoiceTimerCardProps {
|
interface InvoiceTimerCardProps {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
@@ -12,18 +9,10 @@ interface InvoiceTimerCardProps {
|
|||||||
|
|
||||||
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
|
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
|
||||||
<TimeClockPanel
|
<TimeClockPanel
|
||||||
compact
|
compact
|
||||||
defaultClientId={clientId}
|
defaultClientId={clientId}
|
||||||
defaultInvoiceId={invoiceId}
|
defaultInvoiceId={invoiceId}
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" size="sm" className="w-full" asChild>
|
|
||||||
<Link href={`/dashboard/time-clock?clientId=${clientId}&invoiceId=${invoiceId}`}>
|
|
||||||
Open full time clock
|
|
||||||
<ExternalLink className="ml-2 h-3.5 w-3.5" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function PDFDownloadButton({
|
|||||||
{ id: invoiceId },
|
{ id: invoiceId },
|
||||||
{ enabled: false },
|
{ enabled: false },
|
||||||
);
|
);
|
||||||
const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, {
|
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery(undefined, {
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,11 +59,11 @@ export function PDFDownloadButton({
|
|||||||
};
|
};
|
||||||
|
|
||||||
await generateInvoicePDF(pdfData, {
|
await generateInvoicePDF(pdfData, {
|
||||||
pdfTemplate: platformTheme?.pdfTemplate,
|
pdfTemplate: pdfSettings?.pdfTemplate,
|
||||||
pdfAccentColor: platformTheme?.pdfAccentColor,
|
pdfAccentColor: pdfSettings?.pdfAccentColor,
|
||||||
pdfFooterText: platformTheme?.pdfFooterText,
|
pdfFooterText: pdfSettings?.pdfFooterText,
|
||||||
pdfShowLogo: platformTheme?.pdfShowLogo,
|
pdfShowLogo: pdfSettings?.pdfShowLogo,
|
||||||
pdfShowPageNumbers: platformTheme?.pdfShowPageNumbers,
|
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers,
|
||||||
});
|
});
|
||||||
toast.success("PDF downloaded successfully");
|
toast.success("PDF downloaded successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import InvoiceForm from "~/components/forms/invoice-form";
|
import InvoiceForm from "~/components/forms/invoice-form";
|
||||||
|
import { api } from "~/trpc/server";
|
||||||
|
|
||||||
export default function InvoiceFormPage() {
|
interface EditInvoicePageProps {
|
||||||
const params = useParams();
|
params: Promise<{ id: string }>;
|
||||||
const id = params.id as string;
|
}
|
||||||
|
|
||||||
|
export default async function EditInvoicePage({ params }: EditInvoicePageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invoice = await api.invoices.getById({ id });
|
||||||
|
if (invoice.status !== "draft") {
|
||||||
|
redirect(`/dashboard/invoices/${id}?editBlocked=1`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
redirect("/dashboard/invoices");
|
||||||
|
}
|
||||||
|
|
||||||
// Pass the actual id, let the form component handle the logic
|
|
||||||
return <InvoiceForm invoiceId={id} />;
|
return <InvoiceForm invoiceId={id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,17 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound, useParams, useRouter } from "next/navigation";
|
import { notFound, useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { StatusBadge } from "~/components/data/status-badge";
|
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 { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
@@ -83,6 +89,7 @@ function daysSince(date: Date) {
|
|||||||
|
|
||||||
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [recordPaymentOpen, setRecordPaymentOpen] = useState(false);
|
const [recordPaymentOpen, setRecordPaymentOpen] = useState(false);
|
||||||
const [reminderOpen, setReminderOpen] = useState(false);
|
const [reminderOpen, setReminderOpen] = useState(false);
|
||||||
@@ -100,6 +107,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
api.payments.getByInvoice.useQuery({ invoiceId });
|
api.payments.getByInvoice.useQuery({ invoiceId });
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get("editBlocked") === "1") {
|
||||||
|
toast.error("Only draft invoices can be edited");
|
||||||
|
router.replace(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
}
|
||||||
|
}, [searchParams, invoiceId, router]);
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||||
void utils.payments.getByInvoice.invalidate({ invoiceId });
|
void utils.payments.getByInvoice.invalidate({ invoiceId });
|
||||||
@@ -224,24 +238,25 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-24">
|
<DashboardPage className="pb-24">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Invoice Details"
|
title="Invoice Details"
|
||||||
description="View and manage invoice information"
|
description="View and manage invoice information"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
|
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
|
||||||
|
{storedStatus === "draft" ? (
|
||||||
<Button asChild variant="default" className="hover-lift">
|
<Button asChild variant="default" className="hover-lift">
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
<Edit className="mr-2 h-5 w-5" />
|
<Edit className="mr-2 h-5 w-5" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
) : null}
|
||||||
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
|
||||||
{/* Invoice Header */}
|
{/* Invoice Header */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4 sm:p-6">
|
<CardContent className="p-4 sm:p-6">
|
||||||
@@ -531,7 +546,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||||
{storedStatus === "draft" && (
|
{storedStatus === "draft" && (
|
||||||
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
||||||
)}
|
)}
|
||||||
@@ -544,12 +559,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
|
{storedStatus === "draft" ? (
|
||||||
<Button asChild variant="secondary" className="w-full">
|
<Button asChild variant="secondary" className="w-full">
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit Invoice
|
Edit Invoice
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{invoice.items && invoice.client && (
|
{invoice.items && invoice.client && (
|
||||||
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
|
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
|
||||||
@@ -833,7 +850,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from "react";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
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 { Badge } from "~/components/ui/badge";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
@@ -17,7 +16,20 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/components/ui/dialog";
|
} 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 { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||||
import { EmailComposer } from "~/components/forms/email-composer";
|
import { EmailComposer } from "~/components/forms/email-composer";
|
||||||
import { EmailPreview } from "~/components/forms/email-preview";
|
import { EmailPreview } from "~/components/forms/email-preview";
|
||||||
@@ -36,21 +48,20 @@ import {
|
|||||||
|
|
||||||
function SendEmailPageSkeleton() {
|
function SendEmailPageSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Loading..."
|
title="Loading..."
|
||||||
description="Loading invoice email"
|
description="Loading invoice email"
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className={cn("lg:col-span-2", dashboardGapClass, "flex flex-col")}>
|
||||||
<div className="bg-muted h-96 animate-pulse" />
|
<div className="bg-muted h-96 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
<div className={cn(dashboardGapClass, "flex flex-col")}>
|
||||||
<div className="bg-muted h-64 animate-pulse" />
|
<div className="bg-muted h-64 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
</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 toEmail = invoice?.client?.email ?? "";
|
||||||
|
|
||||||
const canSend =
|
const canSend =
|
||||||
@@ -292,18 +303,18 @@ export default function SendEmailPage() {
|
|||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertDescription>Invoice not found.</AlertDescription>
|
<AlertDescription>Invoice not found.</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title={`Send Invoice ${invoice.invoiceNumber}`}
|
title={`Send Invoice ${invoice.invoiceNumber}`}
|
||||||
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"} • ${new Intl.DateTimeFormat(
|
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"} • ${new Intl.DateTimeFormat(
|
||||||
"en-US",
|
"en-US",
|
||||||
@@ -313,7 +324,6 @@ export default function SendEmailPage() {
|
|||||||
day: "numeric",
|
day: "numeric",
|
||||||
},
|
},
|
||||||
).format(new Date())}`}
|
).format(new Date())}`}
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -322,7 +332,7 @@ export default function SendEmailPage() {
|
|||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Invoice
|
Back to Invoice
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
{/* Warning for missing email */}
|
{/* Warning for missing email */}
|
||||||
{(!toEmail || toEmail.trim() === "") && (
|
{(!toEmail || toEmail.trim() === "") && (
|
||||||
@@ -336,22 +346,21 @@ export default function SendEmailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* 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">
|
<div className="lg:col-span-2">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<PageTabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<PageTabsList>
|
||||||
<TabsTrigger value="compose" className="flex items-center gap-2">
|
<PageTabsTrigger value="compose" className="gap-2">
|
||||||
<Edit3 className="h-4 w-4" />
|
<Edit3 className="h-4 w-4" />
|
||||||
Compose
|
Compose
|
||||||
</TabsTrigger>
|
</PageTabsTrigger>
|
||||||
<TabsTrigger value="preview" className="flex items-center gap-2">
|
<PageTabsTrigger value="preview" className="gap-2">
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
Preview
|
Preview
|
||||||
</TabsTrigger>
|
</PageTabsTrigger>
|
||||||
</TabsList>
|
</PageTabsList>
|
||||||
|
|
||||||
<div className="mt-6">
|
<PageTabsContent value="compose">
|
||||||
<TabsContent value="compose" className="space-y-6">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -387,9 +396,9 @@ export default function SendEmailPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
<TabsContent value="preview" className="space-y-6">
|
<PageTabsContent value="preview">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -413,13 +422,12 @@ export default function SendEmailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
</div>
|
</PageTabs>
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-6">
|
<div className={cn(dashboardGapClass, "flex flex-col")}>
|
||||||
{/* Invoice Summary */}
|
{/* Invoice Summary */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -644,6 +652,6 @@ export default function SendEmailPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Send,
|
Send,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -266,16 +267,30 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
{invoice.status === "draft" ? (
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="hover-scale h-8 w-8 p-0"
|
className="hover-scale h-8 w-8 p-0"
|
||||||
data-action-button="true"
|
data-action-button="true"
|
||||||
|
title="Edit invoice"
|
||||||
>
|
>
|
||||||
<Edit className="h-3.5 w-3.5" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="hover-scale h-8 w-8 p-0"
|
||||||
|
data-action-button="true"
|
||||||
|
disabled
|
||||||
|
title="Only draft invoices can be edited"
|
||||||
|
>
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -322,6 +337,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
searchPlaceholder="Search invoices..."
|
searchPlaceholder="Search invoices..."
|
||||||
initialSorting={[{ id: "issueDate", desc: true }]}
|
initialSorting={[{ id: "issueDate", desc: true }]}
|
||||||
filterableColumns={filterableColumns}
|
filterableColumns={filterableColumns}
|
||||||
|
emptyTitle="Create your first invoice"
|
||||||
|
emptyDescription="Send professional invoices and track payments from one place."
|
||||||
|
emptyIcon={<FileText className="h-6 w-6" />}
|
||||||
|
emptyAction={
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/dashboard/invoices/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create invoice
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
onRowClick={(invoice) =>
|
onRowClick={(invoice) =>
|
||||||
router.push(`/dashboard/invoices/${invoice.id}`)
|
router.push(`/dashboard/invoices/${invoice.id}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CSVImportPage } from "~/components/csv-import-page";
|
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 { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
@@ -19,7 +21,7 @@ import { HydrateClient } from "~/trpc/server";
|
|||||||
// File Upload Instructions Component
|
// File Upload Instructions Component
|
||||||
function FormatInstructions() {
|
function FormatInstructions() {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
|
||||||
{/* Required Format */}
|
{/* Required Format */}
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -203,11 +205,10 @@ function FileFormatHelp() {
|
|||||||
|
|
||||||
export default async function ImportPage() {
|
export default async function ImportPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Import Time Entries"
|
title="Import Time Entries"
|
||||||
description="Upload CSV files to create invoices from your time tracking data"
|
description="Upload CSV files to create invoices from your time tracking data"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Link href="/dashboard/invoices">
|
<Link href="/dashboard/invoices">
|
||||||
<Button variant="outline" size="lg">
|
<Button variant="outline" size="lg">
|
||||||
@@ -215,7 +216,7 @@ export default async function ImportPage() {
|
|||||||
Back to Invoices
|
Back to Invoices
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
{/* Main CSV Import Component */}
|
{/* Main CSV Import Component */}
|
||||||
@@ -230,6 +231,6 @@ export default async function ImportPage() {
|
|||||||
{/* Important Notes */}
|
{/* Important Notes */}
|
||||||
<ImportantNotes />
|
<ImportantNotes />
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import Link from "next/link";
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { api, HydrateClient } from "~/trpc/server";
|
import { api, HydrateClient } from "~/trpc/server";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
import { FileText, Plus, Upload } from "lucide-react";
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
|
import { Plus, Upload } from "lucide-react";
|
||||||
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
|
|
||||||
@@ -16,11 +17,10 @@ async function InvoicesTable() {
|
|||||||
|
|
||||||
export default async function InvoicesPage() {
|
export default async function InvoicesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Invoices"
|
title="Invoices"
|
||||||
description="Manage your invoices and track payments"
|
description="Manage your invoices and track payments"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" className="hover-lift shadow-sm">
|
<Button asChild variant="outline" className="hover-lift shadow-sm">
|
||||||
<Link href="/dashboard/invoices/import">
|
<Link href="/dashboard/invoices/import">
|
||||||
@@ -28,25 +28,19 @@ export default async function InvoicesPage() {
|
|||||||
<span>Import CSV</span>
|
<span>Import CSV</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" className="hover-lift shadow-sm">
|
|
||||||
<Link href="/dashboard/invoices/new?blank=1">
|
|
||||||
<FileText className="mr-2 h-5 w-5" />
|
|
||||||
<span>Blank invoice</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
<span>Create Invoice</span>
|
<span>Create Invoice</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
||||||
<InvoicesTable />
|
<InvoicesTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
|
import { EmptyState } from "~/components/layout/page-layout";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
@@ -358,17 +360,16 @@ export default function RecurringInvoicesPage() {
|
|||||||
const isSubmitting = create.isPending || update.isPending;
|
const isSubmitting = create.isPending || update.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-24">
|
<DashboardPage className="pb-24">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Recurring Invoices"
|
title="Recurring Invoices"
|
||||||
description="Schedule automatic invoice generation"
|
description="Schedule automatic invoice generation"
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
New recurring
|
New recurring
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-48 items-center justify-center">
|
<div className="flex h-48 items-center justify-center">
|
||||||
@@ -376,16 +377,18 @@ export default function RecurringInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (recurring ?? []).length === 0 ? (
|
) : (recurring ?? []).length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
<CardContent className="p-0">
|
||||||
<RefreshCw className="text-muted-foreground h-10 w-10" />
|
<EmptyState
|
||||||
<p className="text-muted-foreground text-sm">
|
icon={<RefreshCw className="h-6 w-6" />}
|
||||||
No recurring invoices yet. Create one to automatically generate draft invoices on a
|
title="Create your first recurring invoice"
|
||||||
schedule.
|
description="Automatically generate draft invoices on a schedule you choose."
|
||||||
</p>
|
action={
|
||||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create first recurring invoice
|
Create recurring invoice
|
||||||
</Button>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -529,6 +532,6 @@ export default function RecurringInvoicesPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { api, type RouterOutputs } from "~/trpc/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 { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
@@ -18,7 +25,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/components/ui/dialog";
|
} 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 { toast } from "sonner";
|
||||||
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
|
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");
|
const termsTemplates = templates.filter((t) => t.type === "terms");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-6">
|
<DashboardPage className="pb-6">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Invoice Templates"
|
title="Invoice Templates"
|
||||||
description="Reusable notes and payment terms for your invoices"
|
description="Reusable notes and payment terms for your invoices"
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
|
<PageTabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<PageTabsList>
|
||||||
<TabsTrigger value="notes">
|
<PageTabsTrigger value="notes">
|
||||||
<FileText className="mr-1.5 h-4 w-4" /> Notes (
|
<FileText className="mr-1.5 h-4 w-4" /> Notes (
|
||||||
{notesTemplates.length})
|
{notesTemplates.length})
|
||||||
</TabsTrigger>
|
</PageTabsTrigger>
|
||||||
<TabsTrigger value="terms">
|
<PageTabsTrigger value="terms">
|
||||||
<FileText className="mr-1.5 h-4 w-4" /> Terms (
|
<FileText className="mr-1.5 h-4 w-4" /> Terms (
|
||||||
{termsTemplates.length})
|
{termsTemplates.length})
|
||||||
</TabsTrigger>
|
</PageTabsTrigger>
|
||||||
</TabsList>
|
</PageTabsList>
|
||||||
<TabsContent value="notes" className="mt-4">
|
<PageTabsContent value="notes">
|
||||||
<TemplateList
|
<TemplateList
|
||||||
items={notesTemplates}
|
items={notesTemplates}
|
||||||
type="notes"
|
type="notes"
|
||||||
@@ -214,8 +220,8 @@ export default function TemplatesPage() {
|
|||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={setDeleteId}
|
onDelete={setDeleteId}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
<TabsContent value="terms" className="mt-4">
|
<PageTabsContent value="terms">
|
||||||
<TemplateList
|
<TemplateList
|
||||||
items={termsTemplates}
|
items={termsTemplates}
|
||||||
type="terms"
|
type="terms"
|
||||||
@@ -224,8 +230,8 @@ export default function TemplatesPage() {
|
|||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={setDeleteId}
|
onDelete={setDeleteId}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
</Tabs>
|
</PageTabs>
|
||||||
|
|
||||||
{/* Create/Edit dialog */}
|
{/* Create/Edit dialog */}
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -320,6 +326,6 @@ export default function TemplatesPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AppProviders } from "~/components/providers/app-providers";
|
import { AppProviders } from "~/components/providers/app-providers";
|
||||||
import { DashboardShell } from "~/components/layout/dashboard-shell";
|
import { DashboardShell } from "~/components/layout/dashboard-shell";
|
||||||
|
import { DashboardUserProvider } from "~/components/layout/dashboard-user-context";
|
||||||
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { users } from "~/server/db/schema";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -16,9 +20,22 @@ export default async function DashboardLayout({
|
|||||||
redirect("/auth/signin?callbackUrl=/dashboard");
|
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 (
|
return (
|
||||||
<AppProviders>
|
<AppProviders>
|
||||||
|
<DashboardUserProvider isAdmin={isAdmin} needsOnboarding={needsOnboarding}>
|
||||||
<DashboardShell>{children}</DashboardShell>
|
<DashboardShell>{children}</DashboardShell>
|
||||||
|
</DashboardUserProvider>
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Logo } from "~/components/branding/logo";
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
export function OnboardingShell({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-dashboard text-foreground flex min-h-screen flex-col px-5 py-8 sm:px-6 sm:py-10">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mx-auto flex w-full max-w-xl flex-1 flex-col justify-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-8 space-y-4 text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Logo size="lg" animated={false} />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm leading-6">{brand.tagline}</p>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
export const ONBOARDING_STEPS = [
|
||||||
|
{ id: "welcome", label: "Welcome" },
|
||||||
|
{ id: "business", label: "Business" },
|
||||||
|
{ id: "client", label: "Client" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type OnboardingStepId = (typeof ONBOARDING_STEPS)[number]["id"] | "done";
|
||||||
|
|
||||||
|
function stepIndex(step: OnboardingStepId) {
|
||||||
|
if (step === "done") return ONBOARDING_STEPS.length;
|
||||||
|
return ONBOARDING_STEPS.findIndex((item) => item.id === step);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingStepIndicator({ step }: { step: OnboardingStepId }) {
|
||||||
|
const currentIndex = stepIndex(step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Setup progress" className="mb-8">
|
||||||
|
<ol className="mx-auto flex w-full max-w-md">
|
||||||
|
{ONBOARDING_STEPS.map((item, index) => {
|
||||||
|
const isComplete = currentIndex > index;
|
||||||
|
const isCurrent = currentIndex === index;
|
||||||
|
const isUpcoming = currentIndex < index;
|
||||||
|
const connectorComplete = currentIndex > index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.id} className="flex flex-1 flex-col items-center">
|
||||||
|
<div className="flex w-full items-center">
|
||||||
|
{index > 0 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-0.5 flex-1 rounded-full transition-colors",
|
||||||
|
connectorComplete || isCurrent
|
||||||
|
? "bg-primary"
|
||||||
|
: "bg-border/80",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-2 text-sm font-medium transition-colors",
|
||||||
|
isComplete &&
|
||||||
|
"border-primary bg-primary text-primary-foreground",
|
||||||
|
isCurrent &&
|
||||||
|
"border-primary bg-primary/10 text-primary ring-primary/20 ring-4",
|
||||||
|
isUpcoming &&
|
||||||
|
"border-border/80 bg-background/60 text-muted-foreground",
|
||||||
|
)}
|
||||||
|
aria-current={isCurrent ? "step" : undefined}
|
||||||
|
>
|
||||||
|
{isComplete ? (
|
||||||
|
<Check className="h-4 w-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{index < ONBOARDING_STEPS.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-0.5 flex-1 rounded-full transition-colors",
|
||||||
|
connectorComplete ? "bg-primary" : "bg-border/80",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-2 hidden text-xs font-medium sm:block",
|
||||||
|
isCurrent ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
<p className="text-muted-foreground mt-4 text-center text-sm sm:hidden">
|
||||||
|
Step {Math.min(currentIndex + 1, ONBOARDING_STEPS.length)} of{" "}
|
||||||
|
{ONBOARDING_STEPS.length}
|
||||||
|
{step !== "done" && ONBOARDING_STEPS[currentIndex]
|
||||||
|
? ` · ${ONBOARDING_STEPS[currentIndex].label}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Building2,
|
||||||
|
CheckCircle2,
|
||||||
|
FileText,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { marketingSurfaceClass } from "~/components/marketing/marketing-chrome";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import {
|
||||||
|
OnboardingStepIndicator,
|
||||||
|
type OnboardingStepId,
|
||||||
|
} from "./onboarding-step-indicator";
|
||||||
|
|
||||||
|
type Step = OnboardingStepId;
|
||||||
|
|
||||||
|
function StepIcon({
|
||||||
|
icon: Icon,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/10 text-primary mb-5 inline-flex rounded-2xl p-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OnboardingPanel({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
marketingSurfaceClass,
|
||||||
|
"bg-card/80 px-6 py-8 sm:px-8 sm:py-10",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingWizard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data: status, isLoading } = api.settings.getOnboardingStatus.useQuery();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>("welcome");
|
||||||
|
const [businessName, setBusinessName] = useState("");
|
||||||
|
const [clientName, setClientName] = useState("");
|
||||||
|
|
||||||
|
const createBusiness = api.businesses.create.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Business added");
|
||||||
|
await utils.settings.getOnboardingStatus.invalidate();
|
||||||
|
setStep("client");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createClient = api.clients.create.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Client added");
|
||||||
|
await utils.settings.getOnboardingStatus.invalidate();
|
||||||
|
setStep("done");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeOnboarding = api.settings.completeOnboarding.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status?.completed) {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
}
|
||||||
|
}, [status?.completed, router]);
|
||||||
|
|
||||||
|
const displayStep = useMemo((): Step => {
|
||||||
|
if (step !== "welcome" || !status || status.completed) {
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
if (status.businessCount > 0 && status.clientCount > 0) {
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
if (status.businessCount > 0) {
|
||||||
|
return "client";
|
||||||
|
}
|
||||||
|
return step;
|
||||||
|
}, [step, status]);
|
||||||
|
|
||||||
|
function handleSkip() {
|
||||||
|
completeOnboarding.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBusinessSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!businessName.trim()) {
|
||||||
|
toast.error("Business name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createBusiness.mutate({
|
||||||
|
name: businessName.trim(),
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClientSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!clientName.trim()) {
|
||||||
|
toast.error("Client name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createClient.mutate({ name: clientName.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFinish() {
|
||||||
|
completeOnboarding.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateInvoice() {
|
||||||
|
completeOnboarding.mutate(undefined, {
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push("/dashboard/invoices/new");
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || status?.completed) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[40vh] items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{displayStep !== "done" && <OnboardingStepIndicator step={displayStep} />}
|
||||||
|
|
||||||
|
{displayStep === "welcome" && (
|
||||||
|
<OnboardingPanel>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-primary mb-3 text-sm font-medium tracking-wide uppercase">
|
||||||
|
Quick setup
|
||||||
|
</p>
|
||||||
|
<StepIcon icon={FileText} />
|
||||||
|
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
|
Welcome to {brand.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||||
|
Let's set up the basics so you can send your first invoice.
|
||||||
|
This only takes a minute.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-8 space-y-4">
|
||||||
|
<li className="bg-background/50 border-border/50 flex items-start gap-3 rounded-xl border p-4">
|
||||||
|
<div className="bg-primary/10 text-primary shrink-0 rounded-lg p-2">
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Add your business</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-sm leading-6">
|
||||||
|
The name and details that appear on invoices you send.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="bg-background/50 border-border/50 flex items-start gap-3 rounded-xl border p-4">
|
||||||
|
<div className="bg-primary/10 text-primary shrink-0 rounded-lg p-2">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Add your first client</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-sm leading-6">
|
||||||
|
Who you're billing — you can add more details later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button className="h-11 flex-1" size="lg" onClick={() => setStep("business")}>
|
||||||
|
Get started
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-11"
|
||||||
|
onClick={handleSkip}
|
||||||
|
disabled={completeOnboarding.isPending}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</OnboardingPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayStep === "business" && (
|
||||||
|
<OnboardingPanel>
|
||||||
|
<div className="text-center">
|
||||||
|
<StepIcon icon={Building2} />
|
||||||
|
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
|
Your business
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||||
|
This appears on invoices as the sender — name, logo, and contact
|
||||||
|
details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleBusinessSubmit} className="mt-8 space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="business-name">Business name</Label>
|
||||||
|
<Input
|
||||||
|
id="business-name"
|
||||||
|
value={businessName}
|
||||||
|
onChange={(e) => setBusinessName(e.target.value)}
|
||||||
|
placeholder="Acme Studio LLC"
|
||||||
|
className="h-11"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="h-11 flex-1"
|
||||||
|
disabled={createBusiness.isPending}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-11"
|
||||||
|
onClick={handleSkip}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</OnboardingPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayStep === "client" && (
|
||||||
|
<OnboardingPanel>
|
||||||
|
<div className="text-center">
|
||||||
|
<StepIcon icon={Users} />
|
||||||
|
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
|
Your first client
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||||
|
Who are you billing? You can add more details later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleClientSubmit} className="mt-8 space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="client-name">Client name</Label>
|
||||||
|
<Input
|
||||||
|
id="client-name"
|
||||||
|
value={clientName}
|
||||||
|
onChange={(e) => setClientName(e.target.value)}
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
className="h-11"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="h-11 flex-1"
|
||||||
|
disabled={createClient.isPending}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-11"
|
||||||
|
onClick={handleSkip}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</OnboardingPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayStep === "done" && (
|
||||||
|
<OnboardingPanel className="text-center">
|
||||||
|
<div className="bg-primary/10 text-primary mx-auto mb-5 inline-flex rounded-full p-3">
|
||||||
|
<CheckCircle2 className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
|
You're ready to go
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
|
||||||
|
Your workspace is set up. Create an invoice or explore the dashboard.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button size="lg" className="h-11 flex-1" onClick={handleFinish}>
|
||||||
|
Go to dashboard
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="h-11 flex-1"
|
||||||
|
onClick={handleCreateInvoice}
|
||||||
|
>
|
||||||
|
Create first invoice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</OnboardingPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step !== "welcome" && displayStep !== "done" && (
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
onClick={() =>
|
||||||
|
setStep(displayStep === "client" ? "business" : "welcome")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { OnboardingShell } from "./_components/onboarding-shell";
|
||||||
|
import { OnboardingWizard } from "./_components/onboarding-wizard";
|
||||||
|
|
||||||
|
export default function OnboardingPage() {
|
||||||
|
return (
|
||||||
|
<OnboardingShell>
|
||||||
|
<OnboardingWizard />
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
+166
-284
@@ -10,24 +10,34 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
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 { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import {
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
Card,
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||||
import { HydrateClient, api } from "~/trpc/server";
|
import { cn } from "~/lib/utils";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import { api } from "~/trpc/server";
|
||||||
import { RevenueChart, InvoiceStatusChart, MonthlyMetricsChart } from "~/app/dashboard/_components/charts-client";
|
|
||||||
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
|
|
||||||
import type { DashboardStats, RecentInvoice } from "./types";
|
import type { DashboardStats, RecentInvoice } from "./types";
|
||||||
|
|
||||||
// Hero section with clean mono design
|
|
||||||
|
|
||||||
// Enhanced stats cards with better visuals
|
|
||||||
function DashboardStats({ stats }: { stats: DashboardStats }) {
|
function DashboardStats({ stats }: { stats: DashboardStats }) {
|
||||||
// TODO: Import RouterOutput type
|
|
||||||
const formatTrend = (value: number, isCount = false) => {
|
const formatTrend = (value: number, isCount = false) => {
|
||||||
if (isCount) {
|
if (isCount) {
|
||||||
return value > 0 ? `+${value}` : value.toString();
|
return value > 0 ? `+${value}` : value.toString();
|
||||||
@@ -44,42 +54,42 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
|
|||||||
change: formatTrend(stats.revenueChange),
|
change: formatTrend(stats.revenueChange),
|
||||||
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const),
|
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const),
|
||||||
iconName: "DollarSign" 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 })}`,
|
value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||||
numericValue: stats.pendingAmount,
|
numericValue: stats.pendingAmount,
|
||||||
isCurrency: true,
|
isCurrency: true,
|
||||||
change: "0%", // TODO: Calculate pending change if needed
|
change: "0%",
|
||||||
trend: "neutral" as const,
|
trend: "neutral" as const,
|
||||||
iconName: "Clock" as const,
|
iconName: "Clock" as const,
|
||||||
description: "Invoices awaiting payment",
|
description: "Awaiting payment",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Active Clients",
|
title: "Clients",
|
||||||
value: stats.totalClients.toString(),
|
value: stats.totalClients.toString(),
|
||||||
numericValue: stats.totalClients,
|
numericValue: stats.totalClients,
|
||||||
isCurrency: false,
|
isCurrency: false,
|
||||||
change: "0", // TODO: Calculate client change if needed
|
change: "0",
|
||||||
trend: "neutral" as const,
|
trend: "neutral" as const,
|
||||||
iconName: "Users" as const,
|
iconName: "Users" as const,
|
||||||
description: "Total registered clients",
|
description: "Active clients",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Overdue Invoices",
|
title: "Overdue",
|
||||||
value: stats.overdueCount.toString(),
|
value: stats.overdueCount.toString(),
|
||||||
numericValue: stats.overdueCount,
|
numericValue: stats.overdueCount,
|
||||||
isCurrency: false,
|
isCurrency: false,
|
||||||
change: "0", // TODO: Calculate overdue change if needed
|
change: "0",
|
||||||
trend: "neutral" as const,
|
trend: "neutral" as const,
|
||||||
iconName: "TrendingDown" as const,
|
iconName: "TrendingDown" as const,
|
||||||
description: "Invoices past due date",
|
description: "Past due date",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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) => (
|
{statCards.map((stat, index) => (
|
||||||
<AnimatedStatsCard
|
<AnimatedStatsCard
|
||||||
key={stat.title}
|
key={stat.title}
|
||||||
@@ -98,21 +108,15 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charts section
|
function ChartsSection({ stats }: { stats: DashboardStats }) {
|
||||||
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();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<DashboardGrid className="lg:grid-cols-2">
|
||||||
{/* Revenue Trend Chart */}
|
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<BarChart3 className="h-5 w-5" />
|
<DashboardCardTitle icon={BarChart3}>
|
||||||
Revenue Over Time
|
Revenue over time
|
||||||
|
</DashboardCardTitle>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -120,55 +124,54 @@ async function ChartsSection({ stats }: { stats: DashboardStats }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Invoice Status Breakdown */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<Activity className="h-5 w-5" />
|
<DashboardCardTitle icon={Activity}>
|
||||||
Invoice Status
|
Invoice status
|
||||||
|
</DashboardCardTitle>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<InvoiceStatusChart invoices={invoices} />
|
<InvoiceStatusChart data={stats.statusChartData} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Monthly Metrics */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<Calendar className="h-5 w-5" />
|
<DashboardCardTitle icon={Calendar}>
|
||||||
Monthly Metrics
|
Monthly metrics
|
||||||
|
</DashboardCardTitle>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<MonthlyMetricsChart invoices={invoices} />
|
<MonthlyMetricsChart data={stats.monthlyMetricsChartData} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</DashboardGrid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced Quick Actions
|
|
||||||
function QuickActions() {
|
function QuickActions() {
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
title: "Create Invoice",
|
title: "Create invoice",
|
||||||
description: "Start a new invoice for a client",
|
description: "Start a new invoice for a client",
|
||||||
href: "/dashboard/invoices/new",
|
href: "/dashboard/invoices/new",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
featured: true,
|
featured: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Add Client",
|
title: "Add client",
|
||||||
description: "Register a new client",
|
description: "Register someone you bill",
|
||||||
href: "/dashboard/clients/new",
|
href: "/dashboard/clients/new",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
featured: false,
|
featured: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "View All Invoices",
|
title: "View invoices",
|
||||||
description: "Manage your invoice pipeline",
|
description: "Browse your full pipeline",
|
||||||
href: "/dashboard/invoices",
|
href: "/dashboard/invoices",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
featured: false,
|
featured: false,
|
||||||
@@ -178,27 +181,36 @@ function QuickActions() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<Plus className="h-5 w-5" />
|
<DashboardCardTitle icon={Plus}>Quick actions</DashboardCardTitle>
|
||||||
Quick Actions
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-2">
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={action.title}
|
key={action.title}
|
||||||
href={action.href}
|
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
|
action.featured
|
||||||
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
? "border-primary/20 bg-primary/5 hover:bg-primary/10"
|
||||||
: "border-border bg-background hover:bg-muted/50"
|
: "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">
|
<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">
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||||
{action.description}
|
{action.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -211,204 +223,168 @@ function QuickActions() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current work section with enhanced design
|
function CurrentWork({
|
||||||
async function CurrentWork() {
|
currentDraft,
|
||||||
const invoices = await api.invoices.getAll();
|
}: {
|
||||||
const draftInvoices = invoices.filter(
|
currentDraft: DashboardStats["currentDraft"];
|
||||||
(invoice) =>
|
}) {
|
||||||
getEffectiveInvoiceStatus(
|
if (!currentDraft) {
|
||||||
invoice.status as StoredInvoiceStatus,
|
|
||||||
invoice.dueDate,
|
|
||||||
) === "draft",
|
|
||||||
);
|
|
||||||
const currentInvoice = draftInvoices[0];
|
|
||||||
|
|
||||||
if (!currentInvoice) {
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<Activity className="h-5 w-5" />
|
<DashboardCardTitle icon={Activity}>
|
||||||
Current Work
|
Current work
|
||||||
|
</DashboardCardTitle>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex flex-col items-center py-8 text-center">
|
||||||
<div className="py-8 text-center">
|
<div className="bg-muted mb-4 rounded-2xl p-3">
|
||||||
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
<FileText className="text-muted-foreground h-6 w-6" />
|
||||||
<h3 className="mb-2 text-lg font-semibold">No active drafts</h3>
|
</div>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="font-medium">No draft in progress</p>
|
||||||
Create a new invoice to get started
|
<CardDescription className="mt-1 max-w-xs">
|
||||||
</p>
|
Start an invoice when you're ready to bill your next piece of
|
||||||
<Button asChild variant="outline" className="border-foreground/20">
|
work.
|
||||||
|
</CardDescription>
|
||||||
|
<Button asChild variant="outline" className="mt-5">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create Invoice
|
Create invoice
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalHours =
|
const totalHours = currentDraft.totalHours;
|
||||||
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<Activity className="h-5 w-5" />
|
<DashboardCardTitle icon={Activity}>Current work</DashboardCardTitle>
|
||||||
Current Work
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="secondary">In Progress</Badge>
|
<Badge variant="secondary">Draft</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-5">
|
||||||
<div className="space-y-4">
|
<div className="space-y-1">
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div>
|
||||||
<h3 className="text-lg font-semibold break-words">
|
<p className="font-medium">#{currentDraft.invoiceNumber}</p>
|
||||||
#{currentInvoice.invoiceNumber}
|
<p className="text-muted-foreground text-sm">
|
||||||
</h3>
|
{currentDraft.client?.name}
|
||||||
<span className="text-primary text-2xl font-bold">
|
</p>
|
||||||
${currentInvoice.totalAmount.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between">
|
<p className="font-mono text-xl font-semibold tabular-nums">
|
||||||
<span className="break-words">{currentInvoice.client?.name}</span>
|
${currentDraft.totalAmount.toFixed(2)}
|
||||||
<span className="text-xs sm:text-sm">
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||||
{totalHours.toFixed(1)} hours logged
|
{totalHours.toFixed(1)} hours logged
|
||||||
</span>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||||
asChild
|
<Link href={`/dashboard/invoices/${currentDraft.id}`}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="hover-lift flex-1"
|
|
||||||
>
|
|
||||||
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
View
|
View
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm" className="hover-lift flex-1">
|
<Button asChild size="sm" className="flex-1">
|
||||||
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${currentDraft.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Continue
|
Continue
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced recent activity
|
function RecentActivity({
|
||||||
async function RecentActivity({
|
|
||||||
recentInvoices,
|
recentInvoices,
|
||||||
}: {
|
}: {
|
||||||
recentInvoices: RecentInvoice[];
|
recentInvoices: RecentInvoice[];
|
||||||
}) {
|
}) {
|
||||||
// Use passed recentInvoices instead of fetching all
|
const getStatusVariant = (status: string) => {
|
||||||
|
|
||||||
const getStatusStyle = (status: string) => {
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "paid":
|
case "paid":
|
||||||
return {
|
return "default" as const;
|
||||||
backgroundColor: "oklch(var(--chart-2) / 0.1)",
|
|
||||||
borderColor: "oklch(var(--chart-2) / 0.3)",
|
|
||||||
color: "oklch(var(--chart-2))",
|
|
||||||
};
|
|
||||||
case "sent":
|
case "sent":
|
||||||
return {
|
return "secondary" as const;
|
||||||
backgroundColor: "oklch(var(--chart-1) / 0.1)",
|
|
||||||
borderColor: "oklch(var(--chart-1) / 0.3)",
|
|
||||||
color: "oklch(var(--chart-1))",
|
|
||||||
};
|
|
||||||
case "overdue":
|
case "overdue":
|
||||||
return {
|
return "destructive" as const;
|
||||||
backgroundColor: "oklch(var(--chart-3) / 0.1)",
|
|
||||||
borderColor: "oklch(var(--chart-3) / 0.3)",
|
|
||||||
color: "oklch(var(--chart-3))",
|
|
||||||
};
|
|
||||||
default:
|
default:
|
||||||
return {
|
return "outline" as const;
|
||||||
backgroundColor: "hsl(var(--muted))",
|
|
||||||
borderColor: "hsl(var(--border))",
|
|
||||||
color: "hsl(var(--muted-foreground))",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="h-full">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle>
|
||||||
<Calendar className="h-5 w-5" />
|
<DashboardCardTitle icon={Calendar}>
|
||||||
Recent Activity
|
Recent activity
|
||||||
|
</DashboardCardTitle>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/dashboard/invoices">
|
<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" />
|
<ArrowUpRight className="h-4 w-4 sm:ml-1" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{recentInvoices.length === 0 ? (
|
{recentInvoices.length === 0 ? (
|
||||||
<div className="py-8 text-center">
|
<div className="flex flex-col items-center py-8 text-center">
|
||||||
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
<div className="bg-muted mb-4 rounded-2xl p-3">
|
||||||
<h3 className="mb-2 text-lg font-semibold">No invoices yet</h3>
|
<FileText className="text-muted-foreground h-6 w-6" />
|
||||||
<p className="text-muted-foreground mb-4">
|
</div>
|
||||||
Create your first invoice to get started
|
<p className="font-medium">No invoices yet</p>
|
||||||
</p>
|
<CardDescription className="mt-1 max-w-xs">
|
||||||
<Button asChild variant="outline" className="border-foreground/20">
|
Your latest invoices will show up here.
|
||||||
|
</CardDescription>
|
||||||
|
<Button asChild variant="outline" className="mt-5">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create Your First Invoice
|
Create invoice
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{recentInvoices.map((invoice, _index) => (
|
{recentInvoices.map((invoice) => (
|
||||||
<Link
|
<Link
|
||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
href={`/dashboard/invoices/${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="bg-muted rounded-xl p-2">
|
||||||
<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" />
|
<FileText className="text-muted-foreground h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<p className="truncate text-sm font-medium">
|
||||||
<p className="truncate font-medium">
|
|
||||||
#{invoice.invoiceNumber}
|
#{invoice.invoiceNumber}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground truncate text-sm">
|
<span className="shrink-0 font-mono text-sm font-medium tabular-nums">
|
||||||
{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)}
|
${invoice.totalAmount.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-1 flex items-center justify-between gap-2">
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
{new Date(invoice.issueDate).toLocaleDateString()}
|
{invoice.client?.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<Badge
|
||||||
|
variant={getStatusVariant(invoice.status)}
|
||||||
|
className="shrink-0 text-[10px]"
|
||||||
|
>
|
||||||
|
{invoice.status}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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() {
|
export default async function DashboardPage() {
|
||||||
const session = await getOptionalServerSessionFromHeaders();
|
const session = await getOptionalServerSessionFromHeaders();
|
||||||
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
||||||
|
|
||||||
// Fetch stats centrally
|
|
||||||
const stats = await api.dashboard.getStats();
|
const stats = await api.dashboard.getStats();
|
||||||
void api.timeEntries.getRunning.prefetch();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPageLayout>
|
||||||
<DashboardPageHeader
|
<DashboardPageHeader
|
||||||
title={`Welcome back, ${firstName}!`}
|
title={`Welcome back, ${firstName}`}
|
||||||
description="Here's what's happening with your business today"
|
description="A snapshot of your invoices, revenue, and work in progress."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HydrateClient>
|
|
||||||
<Suspense fallback={<StatsSkeleton />}>
|
|
||||||
<DashboardStats stats={stats} />
|
<DashboardStats stats={stats} />
|
||||||
</Suspense>
|
|
||||||
</HydrateClient>
|
|
||||||
|
|
||||||
<HydrateClient>
|
|
||||||
<Suspense fallback={<ChartsSkeleton />}>
|
|
||||||
<ChartsSection stats={stats} />
|
<ChartsSection stats={stats} />
|
||||||
</Suspense>
|
<DashboardGrid className="lg:grid-cols-2">
|
||||||
</HydrateClient>
|
<div className={cn(dashboardGridClass)}>
|
||||||
|
<CurrentWork currentDraft={stats.currentDraft} />
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<HydrateClient>
|
|
||||||
<Suspense fallback={<CardSkeleton />}>
|
|
||||||
<CurrentWork />
|
|
||||||
</Suspense>
|
|
||||||
</HydrateClient>
|
|
||||||
<QuickActions />
|
<QuickActions />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HydrateClient>
|
|
||||||
<Suspense fallback={<CardSkeleton />}>
|
|
||||||
<RecentActivity recentInvoices={stats.recentInvoices} />
|
<RecentActivity recentInvoices={stats.recentInvoices} />
|
||||||
</Suspense>
|
</DashboardGrid>
|
||||||
</HydrateClient>
|
</DashboardPageLayout>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { api } from "~/trpc/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 { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { StatusBadge } from "~/components/data/status-badge";
|
import { StatusBadge } from "~/components/data/status-badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -14,7 +15,12 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { Separator } from "~/components/ui/separator";
|
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 { formatCurrency } from "~/lib/currency";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
@@ -308,42 +314,40 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Reports"
|
title="Reports"
|
||||||
description="Revenue and tax analytics"
|
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) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
|
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-6">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Reports"
|
title="Reports"
|
||||||
description="Revenue and tax analytics"
|
description="Revenue and tax analytics"
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="overview">
|
<PageTabs defaultValue="overview">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<PageTabsList>
|
||||||
<TabsTrigger value="overview">
|
<PageTabsTrigger value="overview" className="gap-1.5">
|
||||||
<TrendingUp className="mr-1.5 h-4 w-4" /> Overview
|
<TrendingUp className="h-4 w-4" /> Overview
|
||||||
</TabsTrigger>
|
</PageTabsTrigger>
|
||||||
<TabsTrigger value="tax">
|
<PageTabsTrigger value="tax" className="gap-1.5">
|
||||||
<FileText className="mr-1.5 h-4 w-4" /> Tax Summary
|
<FileText className="h-4 w-4" /> Tax Summary
|
||||||
</TabsTrigger>
|
</PageTabsTrigger>
|
||||||
</TabsList>
|
</PageTabsList>
|
||||||
|
|
||||||
{/* ── OVERVIEW TAB ── */}
|
{/* ── OVERVIEW TAB ── */}
|
||||||
<TabsContent value="overview" className="mt-4 space-y-6">
|
<PageTabsContent value="overview">
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
<div className={dashboardStatGridClass}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -630,10 +634,10 @@ export default function ReportsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
{/* ── TAX SUMMARY TAB ── */}
|
{/* ── 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 justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-medium">Tax Year</span>
|
<span className="text-sm font-medium">Tax Year</span>
|
||||||
@@ -840,8 +844,8 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
</Tabs>
|
</PageTabs>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,16 +12,13 @@ import {
|
|||||||
FileUp,
|
FileUp,
|
||||||
Info,
|
Info,
|
||||||
Key,
|
Key,
|
||||||
|
Monitor,
|
||||||
Palette,
|
Palette,
|
||||||
Shield,
|
Shield,
|
||||||
Upload,
|
Upload,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Monitor,
|
|
||||||
PanelLeft,
|
|
||||||
Paintbrush,
|
|
||||||
Type,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { authClient } from "~/lib/auth-client";
|
import { authClient } from "~/lib/auth-client";
|
||||||
@@ -70,11 +67,17 @@ import { Label } from "~/components/ui/label";
|
|||||||
import { Textarea } from "~/components/ui/textarea";
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { Switch } from "~/components/ui/switch";
|
import { Switch } from "~/components/ui/switch";
|
||||||
import { Slider } from "~/components/ui/slider";
|
import { Slider } from "~/components/ui/slider";
|
||||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -83,19 +86,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||||
import {
|
import { brand, colorModes } from "~/lib/branding";
|
||||||
bodyFontPreferences,
|
import type { PdfTemplate } from "~/lib/appearance";
|
||||||
brand,
|
|
||||||
colorModes,
|
|
||||||
colorThemes,
|
|
||||||
type ColorTheme,
|
|
||||||
headingFontPreferences,
|
|
||||||
interfaceThemes,
|
|
||||||
radiusPreferences,
|
|
||||||
sidebarStyles,
|
|
||||||
themePresets,
|
|
||||||
type InterfaceTheme,
|
|
||||||
} from "~/lib/branding";
|
|
||||||
import { ApiAccessSettings } from "./api-access-settings";
|
import { ApiAccessSettings } from "./api-access-settings";
|
||||||
|
|
||||||
const PdfPreviewFrame = dynamic(
|
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) {
|
function isFullHexColor(value: string) {
|
||||||
return /^#[0-9A-Fa-f]{6}$/.test(value);
|
return /^#[0-9A-Fa-f]{6}$/.test(value);
|
||||||
}
|
}
|
||||||
@@ -197,43 +123,28 @@ export function SettingsContent() {
|
|||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
const [isLinking, setIsLinking] = useState(false);
|
const [isLinking, setIsLinking] = useState(false);
|
||||||
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
||||||
const {
|
const { colorMode, updateAppearance, isUpdating: appearanceUpdating } =
|
||||||
interfaceTheme,
|
useAppearance();
|
||||||
bodyFontPreference,
|
const utils = api.useUtils();
|
||||||
headingFontPreference,
|
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery();
|
||||||
radiusPreference,
|
const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({
|
||||||
sidebarStyle,
|
onSuccess: async () => {
|
||||||
colorMode,
|
await utils.settings.getPdfSettings.invalidate();
|
||||||
colorTheme,
|
toast.success("Invoice PDF settings updated");
|
||||||
customColor,
|
},
|
||||||
brandName,
|
onError: (error: { message: string }) => {
|
||||||
brandTagline,
|
toast.error(`Failed to update PDF settings: ${error.message}`);
|
||||||
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 savePdfSettings = (patch: {
|
||||||
|
pdfTemplate?: PdfTemplate;
|
||||||
|
pdfAccentColor?: string;
|
||||||
|
pdfFooterText?: string;
|
||||||
|
pdfShowLogo?: boolean;
|
||||||
|
pdfShowPageNumbers?: boolean;
|
||||||
|
}) => {
|
||||||
|
updatePdfSettingsMutation.mutate(patch);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLinkAuthentik = async () => {
|
const handleLinkAuthentik = async () => {
|
||||||
@@ -320,8 +231,9 @@ export function SettingsContent() {
|
|||||||
|
|
||||||
const importDataMutation = api.settings.importData.useMutation({
|
const importDataMutation = api.settings.importData.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
|
const { imported } = result;
|
||||||
toast.success(
|
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("");
|
setImportData("");
|
||||||
setIsImportDialogOpen(false);
|
setIsImportDialogOpen(false);
|
||||||
@@ -494,16 +406,16 @@ export function SettingsContent() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="general">
|
<PageTabs defaultValue="general">
|
||||||
<TabsList className="bg-muted/50 grid w-full grid-cols-4">
|
<PageTabsList>
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
<PageTabsTrigger value="general">General</PageTabsTrigger>
|
||||||
<TabsTrigger value="preferences">Preferences</TabsTrigger>
|
<PageTabsTrigger value="preferences">Preferences</PageTabsTrigger>
|
||||||
<TabsTrigger value="data">Data</TabsTrigger>
|
<PageTabsTrigger value="data">Data</PageTabsTrigger>
|
||||||
<TabsTrigger value="api">API</TabsTrigger>
|
<PageTabsTrigger value="api">API</PageTabsTrigger>
|
||||||
</TabsList>
|
</PageTabsList>
|
||||||
|
|
||||||
<TabsContent value="general" className="space-y-8">
|
<PageTabsContent value="general">
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
|
||||||
{/* Profile Section */}
|
{/* Profile Section */}
|
||||||
<Card className="form-section bg-card border-border border">
|
<Card className="form-section bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -724,9 +636,9 @@ export function SettingsContent() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
<TabsContent value="preferences" className="space-y-8">
|
<PageTabsContent value="preferences">
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
<CardTitle className="text-foreground flex items-center gap-2">
|
||||||
@@ -734,152 +646,14 @@ export function SettingsContent() {
|
|||||||
Appearance
|
Appearance
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Select the app skin, color mode, accent, and font stack.
|
Choose light, dark, or match your system setting.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{!isAdmin ? (
|
<CardContent className="space-y-4">
|
||||||
<CardContent>
|
<div className="max-w-sm space-y-2">
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Platform appearance and branding are managed by an
|
|
||||||
administrator.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
) : (
|
|
||||||
<CardContent className="space-y-8">
|
|
||||||
<section className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium">Brand</h3>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Public-facing name, logo text, and short product tagline.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Brand Name</Label>
|
|
||||||
<Input
|
|
||||||
value={brandName}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateAppearanceDebounced({
|
|
||||||
brandName: event.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Logo Text</Label>
|
|
||||||
<Input
|
|
||||||
value={brandLogoText}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateAppearanceDebounced({
|
|
||||||
brandLogoText: event.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Brand Icon</Label>
|
|
||||||
<Input
|
|
||||||
value={brandIcon}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateAppearanceDebounced({
|
|
||||||
brandIcon: event.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Tagline</Label>
|
|
||||||
<Input
|
|
||||||
value={brandTagline}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateAppearanceDebounced({
|
|
||||||
brandTagline: event.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-4 border-t pt-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium">Theme</h3>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Presets establish the broad visual language; color mode and
|
|
||||||
accent can still be tuned independently.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<Paintbrush className="h-4 w-4" />
|
|
||||||
Theme Preset
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{themeModified && (
|
|
||||||
<Badge variant="secondary" className="shrink-0">
|
|
||||||
modified
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{themeModified && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
onClick={() => updateAppearance(activePreset)}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={interfaceTheme}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const nextTheme = value as InterfaceTheme;
|
|
||||||
updateAppearance(themePresets[nextTheme]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{interfaceThemes.map((themeOption) => (
|
|
||||||
<SelectItem
|
|
||||||
key={themeOption.value}
|
|
||||||
value={themeOption.value}
|
|
||||||
>
|
|
||||||
{themeOption.label}
|
|
||||||
{themeOption.value === interfaceTheme &&
|
|
||||||
themeModified
|
|
||||||
? " (modified)"
|
|
||||||
: ""}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-muted-foreground text-xs leading-snug">
|
|
||||||
Applies the theme, fonts, accent, corner radius,
|
|
||||||
navigation chrome, and PDF defaults.
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-xs leading-snug">
|
|
||||||
{
|
|
||||||
interfaceThemes.find(
|
|
||||||
(themeOption) => themeOption.value === interfaceTheme,
|
|
||||||
)?.description
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
<Monitor className="h-4 w-4" />
|
<Monitor className="h-4 w-4" />
|
||||||
Color Mode
|
Color mode
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={colorMode}
|
value={colorMode}
|
||||||
@@ -911,271 +685,10 @@ export function SettingsContent() {
|
|||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 && (
|
{appearanceUpdating && (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">Saving...</p>
|
||||||
Saving appearance...
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -1191,7 +704,12 @@ export function SettingsContent() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<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="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1200,12 +718,13 @@ export function SettingsContent() {
|
|||||||
PDF Template
|
PDF Template
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={pdfTemplate}
|
value={pdfSettings?.pdfTemplate ?? "classic"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateAppearance({
|
savePdfSettings({
|
||||||
pdfTemplate: value as typeof pdfTemplate,
|
pdfTemplate: value as PdfTemplate,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
disabled={updatePdfSettingsMutation.isPending}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -1224,13 +743,11 @@ export function SettingsContent() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<InputColor
|
<InputColor
|
||||||
label="PDF Accent"
|
label="PDF Accent"
|
||||||
value={pdfAccentColor}
|
value={pdfSettings?.pdfAccentColor ?? "#111827"}
|
||||||
onBlur={() => undefined}
|
onBlur={() => undefined}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (isFullHexColor(value)) {
|
if (isFullHexColor(value)) {
|
||||||
updateAppearance({
|
savePdfSettings({ pdfAccentColor: value });
|
||||||
pdfAccentColor: value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="mt-0"
|
className="mt-0"
|
||||||
@@ -1241,12 +758,11 @@ export function SettingsContent() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Footer Text</Label>
|
<Label>Footer Text</Label>
|
||||||
<Input
|
<Input
|
||||||
value={pdfFooterText}
|
value={pdfSettings?.pdfFooterText ?? ""}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateAppearanceDebounced({
|
savePdfSettings({ pdfFooterText: event.target.value })
|
||||||
pdfFooterText: event.target.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
disabled={updatePdfSettingsMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1259,10 +775,11 @@ export function SettingsContent() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={pdfShowLogo}
|
checked={pdfSettings?.pdfShowLogo ?? true}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateAppearance({ pdfShowLogo: Boolean(checked) })
|
savePdfSettings({ pdfShowLogo: Boolean(checked) })
|
||||||
}
|
}
|
||||||
|
disabled={updatePdfSettingsMutation.isPending}
|
||||||
aria-label="Toggle PDF logo"
|
aria-label="Toggle PDF logo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1275,12 +792,13 @@ export function SettingsContent() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={pdfShowPageNumbers}
|
checked={pdfSettings?.pdfShowPageNumbers ?? true}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateAppearance({
|
savePdfSettings({
|
||||||
pdfShowPageNumbers: Boolean(checked),
|
pdfShowPageNumbers: Boolean(checked),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
disabled={updatePdfSettingsMutation.isPending}
|
||||||
aria-label="Toggle PDF page numbers"
|
aria-label="Toggle PDF page numbers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1288,13 +806,14 @@ export function SettingsContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PdfPreviewFrame
|
<PdfPreviewFrame
|
||||||
businessName={brandName}
|
businessName={brand.name}
|
||||||
settings={{
|
settings={{
|
||||||
pdfTemplate,
|
pdfTemplate: pdfSettings?.pdfTemplate ?? "classic",
|
||||||
pdfAccentColor,
|
pdfAccentColor: pdfSettings?.pdfAccentColor ?? "#111827",
|
||||||
pdfFooterText,
|
pdfFooterText:
|
||||||
pdfShowLogo,
|
pdfSettings?.pdfFooterText ?? "Professional Invoicing",
|
||||||
pdfShowPageNumbers,
|
pdfShowLogo: pdfSettings?.pdfShowLogo ?? true,
|
||||||
|
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers ?? true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1397,9 +916,9 @@ export function SettingsContent() {
|
|||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
<TabsContent value="data" className="space-y-8">
|
<PageTabsContent value="data">
|
||||||
{/* Data Overview */}
|
{/* Data Overview */}
|
||||||
<Card className="form-section bg-card border-border border">
|
<Card className="form-section bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -1621,7 +1140,7 @@ export function SettingsContent() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Delete Account (Danger Zone) */}
|
{/* Delete Account (Danger Zone) */}
|
||||||
<Card className="border-destructive/50 bg-destructive/5 border">
|
<Card className="bg-card border-destructive/50 border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-destructive flex items-center gap-2">
|
<CardTitle className="text-destructive flex items-center gap-2">
|
||||||
<AlertTriangle className="h-5 w-5" />
|
<AlertTriangle className="h-5 w-5" />
|
||||||
@@ -1672,11 +1191,11 @@ export function SettingsContent() {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
<TabsContent value="api" className="space-y-8">
|
<PageTabsContent value="api">
|
||||||
<ApiAccessSettings />
|
<ApiAccessSettings />
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
</Tabs>
|
</PageTabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { HydrateClient } from "~/trpc/server";
|
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 { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
import { SettingsContent } from "./_components/settings-content";
|
import { SettingsContent } from "./_components/settings-content";
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Settings"
|
title="Settings"
|
||||||
description="Manage your account preferences and data"
|
description="Manage your account preferences and data"
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
@@ -18,6 +18,6 @@ export default async function SettingsPage() {
|
|||||||
<SettingsContent />
|
<SettingsContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { HydrateClient, api } from "~/trpc/server";
|
import { HydrateClient, api } from "~/trpc/server";
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||||
|
|
||||||
export default async function TimeClockPage({
|
export default async function TimeClockPage({
|
||||||
@@ -17,7 +18,7 @@ export default async function TimeClockPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<DashboardPage>
|
||||||
<DashboardPageHeader
|
<DashboardPageHeader
|
||||||
title="Time clock"
|
title="Time clock"
|
||||||
description="Track billable hours and save them directly to an invoice"
|
description="Track billable hours and save them directly to an invoice"
|
||||||
@@ -28,6 +29,6 @@ export default async function TimeClockPage({
|
|||||||
defaultInvoiceId={params.invoiceId}
|
defaultInvoiceId={params.invoiceId}
|
||||||
/>
|
/>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-52
@@ -4,21 +4,33 @@ import { type Metadata } from "next";
|
|||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
|
||||||
import { Toaster } from "~/components/ui/sonner";
|
import { Toaster } from "~/components/ui/sonner";
|
||||||
import {
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
brand,
|
import { brand } from "~/lib/branding";
|
||||||
defaultBodyFontPreference,
|
|
||||||
defaultHeadingFontPreference,
|
|
||||||
defaultInterfaceTheme,
|
|
||||||
defaultRadiusPreference,
|
|
||||||
defaultSidebarStyle,
|
|
||||||
} from "~/lib/branding";
|
|
||||||
|
|
||||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||||
import { BrandBackground } from "~/components/layout/brand-background";
|
import { BrandBackground } from "~/components/layout/brand-background";
|
||||||
|
|
||||||
|
const siteTitle = `${brand.name} - Invoicing Made Simple`;
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `${brand.name} - Invoicing Made Simple`,
|
metadataBase: new URL(getAppUrl()),
|
||||||
|
title: {
|
||||||
|
default: siteTitle,
|
||||||
|
template: `%s | ${brand.name}`,
|
||||||
|
},
|
||||||
description: brand.tagline,
|
description: brand.tagline,
|
||||||
|
openGraph: {
|
||||||
|
title: siteTitle,
|
||||||
|
description: brand.tagline,
|
||||||
|
siteName: brand.name,
|
||||||
|
type: "website",
|
||||||
|
locale: "en_US",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: siteTitle,
|
||||||
|
description: brand.tagline,
|
||||||
|
},
|
||||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,23 +46,6 @@ const playfair = localFont({
|
|||||||
display: "swap",
|
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({
|
const geistMono = localFont({
|
||||||
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
|
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
|
||||||
variable: "--font-geist-mono",
|
variable: "--font-geist-mono",
|
||||||
@@ -64,14 +59,8 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
lang="en"
|
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-mode="system"
|
||||||
data-color-theme="slate"
|
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
|
||||||
className={`${geistSans.variable} ${playfair.variable} ${frutiger.variable} ${geistMono.variable}`}
|
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<script
|
<script
|
||||||
@@ -79,27 +68,11 @@ export default function RootLayout({
|
|||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
try {
|
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 stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
|
||||||
var appearance = Object.assign(defaults, stored);
|
var colorMode = stored.colorMode || "system";
|
||||||
var root = document.documentElement;
|
var root = document.documentElement;
|
||||||
root.dataset.interfaceTheme = appearance.interfaceTheme;
|
root.dataset.colorMode = colorMode;
|
||||||
root.dataset.bodyFont = appearance.bodyFontPreference;
|
if (colorMode === "dark") root.classList.add("dark");
|
||||||
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);
|
|
||||||
} catch {}
|
} catch {}
|
||||||
`,
|
`,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
|
||||||
|
import { brand, splitLogoText } from "~/lib/branding";
|
||||||
|
|
||||||
|
export const alt = `${brand.name} - Invoicing Made Simple`;
|
||||||
|
export const size = { width: 1200, height: 630 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
export default async function Image() {
|
||||||
|
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
|
||||||
|
|
||||||
|
const geistMono = await fetch(
|
||||||
|
new URL(
|
||||||
|
"../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
).then((res) => res.arrayBuffer());
|
||||||
|
|
||||||
|
const playfair = await fetch(
|
||||||
|
new URL(
|
||||||
|
"../../node_modules/@fontsource-variable/playfair-display/files/playfair-display-latin-wght-normal.woff2",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
).then((res) => res.arrayBuffer());
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage:
|
||||||
|
"linear-gradient(to right, rgba(128,128,128,0.07) 1px, transparent 1px), linear-gradient(to bottom, rgba(128,128,128,0.07) 1px, transparent 1px)",
|
||||||
|
backgroundSize: "24px 24px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: 520,
|
||||||
|
height: 520,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "rgba(163, 163, 163, 0.25)",
|
||||||
|
filter: "blur(80px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1,
|
||||||
|
padding: "0 80px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
fontFamily: "Geist Mono",
|
||||||
|
fontSize: 72,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#18181b" }}>{brand.icon}</span>
|
||||||
|
<span style={{ width: 16 }} />
|
||||||
|
<span style={{ color: "#09090b" }}>{logoPrefix}</span>
|
||||||
|
<span style={{ color: "rgba(9, 9, 11, 0.7)" }}>{logoSuffix}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 32,
|
||||||
|
fontFamily: "Playfair Display",
|
||||||
|
fontSize: 40,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#09090b",
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Invoicing Made Simple
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
fontFamily: "Geist Mono",
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 400,
|
||||||
|
color: "#71717a",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: 900,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{brand.tagline}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...size,
|
||||||
|
fonts: [
|
||||||
|
{
|
||||||
|
name: "Geist Mono",
|
||||||
|
data: geistMono,
|
||||||
|
style: "normal",
|
||||||
|
weight: 700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Playfair Display",
|
||||||
|
data: playfair,
|
||||||
|
style: "normal",
|
||||||
|
weight: 600,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { brand } from "~/lib/branding";
|
import { brand, splitLogoText } from "~/lib/branding";
|
||||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
@@ -11,24 +10,8 @@ interface LogoProps {
|
|||||||
animated?: boolean;
|
animated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitLogoText(logoText: string) {
|
|
||||||
const voiceIndex = logoText.toLowerCase().indexOf("voice");
|
|
||||||
|
|
||||||
if (voiceIndex > 0) {
|
|
||||||
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
logoText.slice(0, Math.ceil(logoText.length / 2)),
|
|
||||||
logoText.slice(Math.ceil(logoText.length / 2)),
|
|
||||||
] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
||||||
const appearance = useAppearance();
|
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
|
||||||
const logoText = appearance.brandLogoText || brand.logoText;
|
|
||||||
const icon = appearance.brandIcon || brand.icon;
|
|
||||||
const [logoPrefix, logoSuffix] = splitLogoText(logoText);
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: "text-base",
|
sm: "text-base",
|
||||||
md: "text-xl",
|
md: "text-xl",
|
||||||
@@ -45,7 +28,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
|||||||
sizeClasses={sizeClasses}
|
sizeClasses={sizeClasses}
|
||||||
logoPrefix={logoPrefix}
|
logoPrefix={logoPrefix}
|
||||||
logoSuffix={logoSuffix}
|
logoSuffix={logoSuffix}
|
||||||
icon={icon}
|
icon={brand.icon}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,7 +50,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
|||||||
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
|
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
|
||||||
className="text-primary font-bold tracking-tight"
|
className="text-primary font-bold tracking-tight"
|
||||||
>
|
>
|
||||||
{icon}
|
{brand.icon}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
{size !== "icon" && (
|
{size !== "icon" && (
|
||||||
<>
|
<>
|
||||||
@@ -75,8 +58,8 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }}
|
transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }}
|
||||||
className="inline-block w-1" // Reduced from w-2 to w-1 (half space)
|
className="inline-block w-1"
|
||||||
></motion.span>
|
/>
|
||||||
<motion.span
|
<motion.span
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@@ -125,7 +108,7 @@ function LogoContent({
|
|||||||
<span className="text-primary font-bold tracking-tight">{icon}</span>
|
<span className="text-primary font-bold tracking-tight">{icon}</span>
|
||||||
{size !== "icon" && (
|
{size !== "icon" && (
|
||||||
<>
|
<>
|
||||||
<span className="inline-block w-1"></span>
|
<span className="inline-block w-1" />
|
||||||
<span className="text-foreground font-bold tracking-tight">
|
<span className="text-foreground font-bold tracking-tight">
|
||||||
{logoPrefix}
|
{logoPrefix}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { ResponsiveContainer } from "recharts";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
interface ResponsiveChartProps {
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
children: ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsiveChart({
|
||||||
|
height = 256,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: ResponsiveChartProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("w-full min-w-0", className)}>
|
||||||
|
<ResponsiveContainer width="100%" height={height} minWidth={0}>
|
||||||
|
{children}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,10 +24,12 @@ import {
|
|||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
Filter,
|
Filter,
|
||||||
Search,
|
Search,
|
||||||
|
SearchX,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { EmptyState } from "~/components/layout/page-layout";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card } from "~/components/ui/card";
|
import { Card } from "~/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -87,6 +89,41 @@ interface DataTableProps<TData, TValue> {
|
|||||||
clearSelection: () => void,
|
clearSelection: () => void,
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
initialSorting?: SortingState;
|
initialSorting?: SortingState;
|
||||||
|
/** Shown when the dataset is empty (no rows in DB). */
|
||||||
|
emptyTitle?: string;
|
||||||
|
emptyDescription?: string;
|
||||||
|
emptyIcon?: React.ReactNode;
|
||||||
|
emptyAction?: React.ReactNode;
|
||||||
|
/** Shown when filters/search hide all rows but data exists. */
|
||||||
|
filteredEmptyTitle?: string;
|
||||||
|
filteredEmptyDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableEmptyStateProps {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Centered empty state for data tables (reuses page EmptyState). */
|
||||||
|
export function DataTableEmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
}: DataTableEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={icon}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
action={action}
|
||||||
|
className={cn("py-16", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
@@ -106,6 +143,12 @@ export function DataTable<TData, TValue>({
|
|||||||
onRowClick,
|
onRowClick,
|
||||||
selectionActions,
|
selectionActions,
|
||||||
initialSorting = [],
|
initialSorting = [],
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
emptyIcon,
|
||||||
|
emptyAction,
|
||||||
|
filteredEmptyTitle = "No matches for your search",
|
||||||
|
filteredEmptyDescription = "Try adjusting your search or filters.",
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
|
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
|
||||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||||
@@ -190,6 +233,9 @@ export function DataTable<TData, TValue>({
|
|||||||
}, [globalFilter]);
|
}, [globalFilter]);
|
||||||
|
|
||||||
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
|
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
|
||||||
|
const filteredRowCount = table.getFilteredRowModel().rows.length;
|
||||||
|
const isDatasetEmpty = data.length === 0;
|
||||||
|
const isFilteredEmpty = !isDatasetEmpty && filteredRowCount === 0;
|
||||||
|
|
||||||
// Handle row click
|
// Handle row click
|
||||||
const handleRowClick = (row: TData, event: React.MouseEvent) => {
|
const handleRowClick = (row: TData, event: React.MouseEvent) => {
|
||||||
@@ -419,12 +465,26 @@ export function DataTable<TData, TValue>({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableCell
|
<TableCell colSpan={columns.length} className="p-0">
|
||||||
colSpan={columns.length}
|
{isDatasetEmpty && emptyTitle ? (
|
||||||
className="h-24 text-center"
|
<DataTableEmptyState
|
||||||
>
|
icon={emptyIcon}
|
||||||
<p className="text-muted-foreground">No results found</p>
|
title={emptyTitle}
|
||||||
|
description={emptyDescription}
|
||||||
|
action={emptyAction}
|
||||||
|
/>
|
||||||
|
) : isFilteredEmpty ? (
|
||||||
|
<DataTableEmptyState
|
||||||
|
icon={<SearchX className="h-6 w-6" />}
|
||||||
|
title={filteredEmptyTitle}
|
||||||
|
description={filteredEmptyDescription}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground py-16 text-center text-sm">
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -155,11 +155,22 @@ export function InvoiceList() {
|
|||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
{invoice.status === "draft" ? (
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
title="Only draft invoices can be edited"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import { useEffect, useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AddressForm } from "~/components/forms/address-form";
|
import { AddressForm } from "~/components/forms/address-form";
|
||||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
@@ -408,7 +410,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
(mode === "edit" && isLoadingEmailConfig)
|
(mode === "edit" && isLoadingEmailConfig)
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
@@ -430,21 +432,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title={mode === "edit" ? "Edit Business" : "Add Business"}
|
title={mode === "edit" ? "Edit Business" : "Add Business"}
|
||||||
description={
|
description={
|
||||||
mode === "edit"
|
mode === "edit"
|
||||||
? "Update business information below"
|
? "Update business information below"
|
||||||
: "Enter business details below to add a new business."
|
: "Enter business details below to add a new business."
|
||||||
}
|
}
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -469,9 +470,13 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<form id="business-form" onSubmit={handleSubmit} className="space-y-6">
|
<form
|
||||||
|
id="business-form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className={cn("flex flex-col", dashboardGapClass)}
|
||||||
|
>
|
||||||
{/* Main Form Container - styled like data table */}
|
{/* Main Form Container - styled like data table */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Basic Information */}
|
{/* Basic Information */}
|
||||||
@@ -902,7 +907,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DashboardPage>
|
||||||
|
|
||||||
<FloatingActionBar
|
<FloatingActionBar
|
||||||
leftContent={
|
leftContent={
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import { Label } from "~/components/ui/label";
|
|||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { AddressForm } from "~/components/forms/address-form";
|
import { AddressForm } from "~/components/forms/address-form";
|
||||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import {
|
import {
|
||||||
@@ -237,7 +239,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
|
|
||||||
if (mode === "edit" && isLoadingClient) {
|
if (mode === "edit" && isLoadingClient) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
@@ -259,21 +261,20 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6 pb-32">
|
<DashboardPage className="pb-32">
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title={mode === "edit" ? "Edit Client" : "Add Client"}
|
title={mode === "edit" ? "Edit Client" : "Add Client"}
|
||||||
description={
|
description={
|
||||||
mode === "edit"
|
mode === "edit"
|
||||||
? "Update client information below"
|
? "Update client information below"
|
||||||
: "Enter client details below to add a new client."
|
: "Enter client details below to add a new client."
|
||||||
}
|
}
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -298,9 +299,13 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<form id="client-form" onSubmit={handleSubmit} className="space-y-6">
|
<form
|
||||||
|
id="client-form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className={cn("flex flex-col", dashboardGapClass)}
|
||||||
|
>
|
||||||
{/* Main Form Container - styled like data table */}
|
{/* Main Form Container - styled like data table */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Basic Information */}
|
{/* Basic Information */}
|
||||||
@@ -508,7 +513,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DashboardPage>
|
||||||
|
|
||||||
<FloatingActionBar
|
<FloatingActionBar
|
||||||
leftContent={
|
leftContent={
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||||
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
|
|
||||||
interface EmailPreviewProps {
|
interface EmailPreviewProps {
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -89,10 +90,7 @@ export function EmailPreview({
|
|||||||
customMessage: customMessage,
|
customMessage: customMessage,
|
||||||
userName: invoice.business?.name ?? "Your Business",
|
userName: invoice.business?.name ?? "Your Business",
|
||||||
userEmail: fromEmail,
|
userEmail: fromEmail,
|
||||||
baseUrl:
|
baseUrl: getAppUrl(),
|
||||||
typeof window !== "undefined"
|
|
||||||
? window.location.origin
|
|
||||||
: "https://beenvoice.app",
|
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,20 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import {
|
||||||
|
PageTabs,
|
||||||
|
PageTabsContent,
|
||||||
|
PageTabsList,
|
||||||
|
PageTabsTrigger,
|
||||||
|
pageTabsGridClass,
|
||||||
|
} from "~/components/layout/page-tabs";
|
||||||
|
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||||
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -17,7 +26,6 @@ import {
|
|||||||
import { DatePicker } from "~/components/ui/date-picker";
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import { InvoiceLineItems } from "./invoice-line-items";
|
import { InvoiceLineItems } from "./invoice-line-items";
|
||||||
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
||||||
import { EmailPreview } from "./email-preview";
|
import { EmailPreview } from "./email-preview";
|
||||||
@@ -62,19 +70,17 @@ interface InvoiceFormProps {
|
|||||||
|
|
||||||
function InvoiceFormSkeleton() {
|
function InvoiceFormSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-8">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title="Loading..."
|
title="Loading..."
|
||||||
description="Loading invoice form"
|
description="Loading invoice form"
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />{" "}
|
<div className="bg-muted h-10 w-full animate-pulse rounded-xl p-1" />
|
||||||
{/* Tabs Skeleton */}
|
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
|
||||||
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
||||||
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DashboardPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +101,7 @@ function plainTextToHtml(value: string) {
|
|||||||
.replace(/\n/g, "<br>");
|
.replace(/\n/g, "<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||||
return {
|
return {
|
||||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
||||||
invoicePrefix: "#",
|
invoicePrefix: "#",
|
||||||
@@ -109,9 +115,7 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
|||||||
taxRate: 0,
|
taxRate: 0,
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
defaultHourlyRate: null,
|
defaultHourlyRate: null,
|
||||||
items: blank
|
items: [
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
@@ -126,13 +130,11 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
|||||||
|
|
||||||
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const isBlank = searchParams.get("blank") === "1";
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [formData, setFormData] = useState<InvoiceFormData>(() =>
|
const [formData, setFormData] = useState<InvoiceFormData>(() =>
|
||||||
createDefaultInvoiceFormData(isBlank),
|
createDefaultInvoiceFormData(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -140,15 +142,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState("details");
|
const [activeTab, setActiveTab] = useState("details");
|
||||||
const [previewTab, setPreviewTab] = useState("pdf");
|
const [previewTab, setPreviewTab] = useState("pdf");
|
||||||
const [previewPinned, setPreviewPinned] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const media = window.matchMedia("(min-width: 1024px)");
|
|
||||||
const update = () => setPreviewPinned(media.matches);
|
|
||||||
update();
|
|
||||||
media.addEventListener("change", update);
|
|
||||||
return () => media.removeEventListener("change", update);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Queries (Same as before)
|
// Queries (Same as before)
|
||||||
const { data: clients, isLoading: loadingClients } =
|
const { data: clients, isLoading: loadingClients } =
|
||||||
@@ -179,7 +172,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
}, [invoiceId]);
|
}, [invoiceId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
||||||
// ... (Mapping logic same as before)
|
|
||||||
const mappedItems: InvoiceItem[] =
|
const mappedItems: InvoiceItem[] =
|
||||||
existingInvoice.items?.map((item) => ({
|
existingInvoice.items?.map((item) => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -231,6 +223,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
}
|
}
|
||||||
}, [invoiceId, existingInvoice, businesses, initialized]);
|
}, [invoiceId, existingInvoice, businesses, initialized]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
invoiceId &&
|
||||||
|
invoiceId !== "new" &&
|
||||||
|
existingInvoice &&
|
||||||
|
!loadingInvoice &&
|
||||||
|
existingInvoice.status !== "draft"
|
||||||
|
) {
|
||||||
|
toast.error("Only draft invoices can be edited");
|
||||||
|
router.replace(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
}
|
||||||
|
}, [invoiceId, existingInvoice, loadingInvoice, router]);
|
||||||
|
|
||||||
const totals = React.useMemo(() => {
|
const totals = React.useMemo(() => {
|
||||||
const subtotal = formData.items.reduce(
|
const subtotal = formData.items.reduce(
|
||||||
(sum, item) => sum + item.hours * item.rate,
|
(sum, item) => sum + item.hours * item.rate,
|
||||||
@@ -456,27 +461,20 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
!initialized ||
|
!initialized ||
|
||||||
loadingClients ||
|
loadingClients ||
|
||||||
loadingBusinesses ||
|
loadingBusinesses ||
|
||||||
(invoiceId && invoiceId !== "new" && loadingInvoice)
|
(invoiceId && invoiceId !== "new" && loadingInvoice) ||
|
||||||
|
(invoiceId &&
|
||||||
|
invoiceId !== "new" &&
|
||||||
|
existingInvoice &&
|
||||||
|
existingInvoice.status !== "draft")
|
||||||
)
|
)
|
||||||
return <InvoiceFormSkeleton />;
|
return <InvoiceFormSkeleton />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-enter space-y-6 pb-8">
|
<DashboardPage>
|
||||||
<PageHeader
|
<DashboardPageHeader
|
||||||
title={
|
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
|
||||||
invoiceId !== "new"
|
description="Manage your invoice"
|
||||||
? "Edit Invoice"
|
|
||||||
: isBlank
|
|
||||||
? "Blank Invoice"
|
|
||||||
: "Create Invoice"
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
isBlank
|
|
||||||
? "Set up a draft to clock time into later"
|
|
||||||
: "Manage your invoice"
|
|
||||||
}
|
|
||||||
variant="gradient"
|
|
||||||
>
|
>
|
||||||
{invoiceId !== "new" && (
|
{invoiceId !== "new" && (
|
||||||
<Button
|
<Button
|
||||||
@@ -491,43 +489,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
{loading ? "Saving..." : "Save"}
|
{loading ? "Saving..." : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</DashboardPageHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
|
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||||
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
<PageTabsList>
|
||||||
{/* TAB SELECTOR: w-full, p-1, visible background */}
|
<PageTabsTrigger value="details">Details</PageTabsTrigger>
|
||||||
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1 lg:grid-cols-3">
|
<PageTabsTrigger value="items">Items</PageTabsTrigger>
|
||||||
<TabsTrigger
|
<PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
|
||||||
value="details"
|
<PageTabsTrigger value="preview">Preview</PageTabsTrigger>
|
||||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
</PageTabsList>
|
||||||
>
|
|
||||||
Details
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="items"
|
|
||||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
|
||||||
>
|
|
||||||
Items
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="timesheet"
|
|
||||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
|
||||||
>
|
|
||||||
Timesheet
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="preview"
|
|
||||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm lg:hidden"
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* DETAILS TAB */}
|
{/* DETAILS TAB */}
|
||||||
<TabsContent
|
<PageTabsContent value="details">
|
||||||
value="details"
|
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
|
||||||
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
|
|
||||||
>
|
|
||||||
<Card className="h-full">
|
<Card className="h-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex gap-2 text-base">
|
<CardTitle className="flex gap-2 text-base">
|
||||||
@@ -770,14 +744,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</div>
|
||||||
|
</PageTabsContent>
|
||||||
|
|
||||||
{/* ITEMS TAB */}
|
{/* ITEMS TAB */}
|
||||||
<TabsContent
|
<PageTabsContent value="items">
|
||||||
value="items"
|
<div className={cn(pageTabsGridClass, "md:grid-cols-3")}>
|
||||||
className="mt-6 focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
||||||
<Card className="bg-primary/5 border-primary/20">
|
<Card className="bg-primary/5 border-primary/20">
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
<span className="text-muted-foreground">Total</span>
|
<span className="text-muted-foreground">Total</span>
|
||||||
@@ -826,13 +798,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
{/* TIMESHEET TAB */}
|
{/* TIMESHEET TAB */}
|
||||||
<TabsContent
|
<PageTabsContent value="timesheet">
|
||||||
value="timesheet"
|
|
||||||
className="mt-6 focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
<Card className="min-h-[600px] w-full">
|
<Card className="min-h-[600px] w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex gap-2">
|
<CardTitle className="flex gap-2">
|
||||||
@@ -850,44 +819,51 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<PageTabsContent value="preview">
|
||||||
value="preview"
|
<Card className="overflow-hidden">
|
||||||
className="mt-6 focus-visible:outline-none"
|
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-3">
|
||||||
>
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Tabs
|
<FileText className="h-4 w-4" />
|
||||||
value={previewTab}
|
Preview
|
||||||
onValueChange={setPreviewTab}
|
</CardTitle>
|
||||||
className="w-full"
|
<div className="bg-muted flex rounded-lg p-1 text-sm">
|
||||||
>
|
<button
|
||||||
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1">
|
type="button"
|
||||||
<TabsTrigger
|
onClick={() => setPreviewTab("pdf")}
|
||||||
value="pdf"
|
className={cn(
|
||||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||||
|
previewTab === "pdf"
|
||||||
|
? "bg-background text-foreground shadow"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
PDF
|
PDF
|
||||||
</TabsTrigger>
|
</button>
|
||||||
<TabsTrigger
|
<button
|
||||||
value="email"
|
type="button"
|
||||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
onClick={() => setPreviewTab("email")}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||||
|
previewTab === "email"
|
||||||
|
? "bg-background text-foreground shadow"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Email
|
Email
|
||||||
</TabsTrigger>
|
</button>
|
||||||
</TabsList>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="pdf" className="mt-6">
|
|
||||||
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="email" className="mt-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex gap-2">
|
|
||||||
<Mail className="h-5 w-5" /> Email Preview
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
|
{previewTab === "pdf" ? (
|
||||||
|
<InvoicePdfPreviewPanel
|
||||||
|
embedded
|
||||||
|
input={pdfPreviewInput}
|
||||||
|
enabled={activeTab === "preview" && previewTab === "pdf"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="border-t p-6">
|
||||||
<EmailPreview
|
<EmailPreview
|
||||||
subject={`Invoice ${formData.invoiceNumber} from ${
|
subject={`Invoice ${formData.invoiceNumber} from ${
|
||||||
selectedBusiness?.name ?? "Your Business"
|
selectedBusiness?.name ?? "Your Business"
|
||||||
@@ -926,31 +902,13 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
})),
|
})),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</PageTabsContent>
|
||||||
</Tabs>
|
</PageTabs>
|
||||||
</TabsContent>
|
</DashboardPage>
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<aside className="hidden lg:block">
|
|
||||||
<div className="sticky top-4 space-y-4">
|
|
||||||
<InvoicePdfPreviewPanel
|
|
||||||
input={pdfPreviewInput}
|
|
||||||
enabled={previewPinned || activeTab === "preview"}
|
|
||||||
/>
|
|
||||||
<Card className="border-primary/20 bg-primary/5">
|
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
|
||||||
<span className="text-muted-foreground text-sm">Invoice total</span>
|
|
||||||
<span className="font-mono text-2xl font-bold">
|
|
||||||
<CountUp value={totals.total} prefix="$" />
|
|
||||||
</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
|
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -260,7 +260,7 @@ function MobileLineItem({
|
|||||||
date={item.date}
|
date={item.date}
|
||||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-[92px] shrink-0"
|
className="w-auto shrink-0"
|
||||||
inputClassName="h-8 px-2 text-xs"
|
inputClassName="h-8 px-2 text-xs"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
@@ -374,7 +374,7 @@ export function InvoiceLineItems({
|
|||||||
) : null}
|
) : null}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
||||||
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
|
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
|
||||||
<span>Date</span>
|
<span>Date</span>
|
||||||
<span>Description</span>
|
<span>Description</span>
|
||||||
<span className="text-center">Hours</span>
|
<span className="text-center">Hours</span>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ type InvoicePdfPreviewPanelProps = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
heightClassName?: string;
|
heightClassName?: string;
|
||||||
|
/** Renders only the preview body (no card/header) for embedding in a parent pane. */
|
||||||
|
embedded?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InvoicePdfPreviewPanel({
|
export function InvoicePdfPreviewPanel({
|
||||||
@@ -44,6 +46,7 @@ export function InvoicePdfPreviewPanel({
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
className,
|
className,
|
||||||
heightClassName = "h-[min(80vh,760px)]",
|
heightClassName = "h-[min(80vh,760px)]",
|
||||||
|
embedded = false,
|
||||||
}: InvoicePdfPreviewPanelProps) {
|
}: InvoicePdfPreviewPanelProps) {
|
||||||
const previewReady = canPreview(input);
|
const previewReady = canPreview(input);
|
||||||
|
|
||||||
@@ -54,16 +57,7 @@ export function InvoicePdfPreviewPanel({
|
|||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const previewBody = (
|
||||||
<Card className={cn("overflow-hidden", className)}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
PDF preview
|
|
||||||
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted/20 overflow-hidden border-t",
|
"bg-muted/20 overflow-hidden border-t",
|
||||||
@@ -99,7 +93,22 @@ export function InvoicePdfPreviewPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <div className={cn("overflow-hidden", className)}>{previewBody}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn("overflow-hidden", className)}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
PDF preview
|
||||||
|
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">{previewBody}</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** Vertical rhythm for dashboard pages — use with shell `gap-5`. */
|
||||||
|
export const dashboardGapClass = "gap-5 md:gap-6";
|
||||||
|
|
||||||
|
/** Standard grid gap for dashboard cards and sections. */
|
||||||
|
export const dashboardGridClass =
|
||||||
|
"grid gap-5 md:gap-6";
|
||||||
|
|
||||||
|
/** Summary stat cards (2-up mobile, 4-up desktop). */
|
||||||
|
export const dashboardStatGridClass =
|
||||||
|
"grid grid-cols-2 gap-4 sm:grid-cols-4";
|
||||||
|
|
||||||
|
export function DashboardPage({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"page-enter mx-auto flex w-full max-w-7xl flex-col",
|
||||||
|
dashboardGapClass,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardGrid({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn(dashboardGridClass, className)}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardCardTitle({
|
||||||
|
children,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{Icon ? <Icon className="text-muted-foreground h-4 w-4" /> : null}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { Sidebar } from "~/components/layout/sidebar";
|
import { Sidebar } from "~/components/layout/sidebar";
|
||||||
import {
|
import {
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
@@ -11,23 +12,29 @@ import { Menu } from "lucide-react";
|
|||||||
import { Logo } from "~/components/branding/logo";
|
import { Logo } from "~/components/branding/logo";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
||||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
|
||||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||||
|
import { OnboardingGuard } from "~/components/layout/onboarding-guard";
|
||||||
|
|
||||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||||
const { isCollapsed } = useSidebar();
|
const { isCollapsed } = useSidebar();
|
||||||
const { sidebarStyle } = useAppearance();
|
const pathname = usePathname();
|
||||||
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
|
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
|
||||||
|
const isOnboarding = pathname === "/dashboard/onboarding";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-dashboard relative flex min-h-screen">
|
<div className="bg-dashboard relative flex min-h-screen">
|
||||||
{/* Desktop Sidebar */}
|
{!isOnboarding && (
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mobile Sidebar (Sheet) */}
|
<div
|
||||||
<div className="dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden">
|
className={cn(
|
||||||
|
"dashboard-mobile-header bg-background/80 border-border fixed top-0 right-0 left-0 z-50 flex min-h-16 items-center border-b px-3 backdrop-blur-md sm:px-4 md:hidden",
|
||||||
|
isOnboarding && "hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
|
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -40,9 +47,9 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
|||||||
<span className="sr-only">Toggle menu</span>
|
<span className="sr-only">Toggle menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
{/* Mobile Link / Logo */}
|
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
|
||||||
<div className="ml-4 flex items-center gap-2">
|
<Logo size="sm" className="shrink-0" />
|
||||||
<Logo size="sm" />
|
<ActiveTimerWidget compact />
|
||||||
</div>
|
</div>
|
||||||
<SheetContent side="left" className="w-72 p-0">
|
<SheetContent side="left" className="w-72 p-0">
|
||||||
<div className="sr-only">
|
<div className="sr-only">
|
||||||
@@ -53,30 +60,20 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main
|
<main
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out",
|
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0",
|
||||||
"md:ml-0",
|
!isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"),
|
||||||
sidebarStyle === "floating"
|
|
||||||
? isCollapsed
|
|
||||||
? "md:ml-24"
|
|
||||||
: "md:ml-[18rem]"
|
|
||||||
: isCollapsed
|
|
||||||
? "md:ml-16"
|
|
||||||
: "md:ml-64",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="dashboard-content-shell p-4 pt-16 md:pt-4">
|
{isOnboarding ? (
|
||||||
<div className="mb-4 md:hidden">
|
<OnboardingGuard>{children}</OnboardingGuard>
|
||||||
{/* Mobile Breadcrumbs could go here or be part of the page */}
|
) : (
|
||||||
</div>
|
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
|
||||||
<div className="mb-4">
|
<OnboardingGuard>{children}</OnboardingGuard>
|
||||||
<ActiveTimerWidget />
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
interface DashboardUserContextValue {
|
||||||
|
isAdmin: boolean;
|
||||||
|
needsOnboarding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardUserContext = createContext<DashboardUserContextValue>({
|
||||||
|
isAdmin: false,
|
||||||
|
needsOnboarding: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function DashboardUserProvider({
|
||||||
|
isAdmin,
|
||||||
|
needsOnboarding,
|
||||||
|
children,
|
||||||
|
}: DashboardUserContextValue & { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<DashboardUserContext.Provider value={{ isAdmin, needsOnboarding }}>
|
||||||
|
{children}
|
||||||
|
</DashboardUserContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboardUser() {
|
||||||
|
return useContext(DashboardUserContext);
|
||||||
|
}
|
||||||
@@ -3,15 +3,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
|
||||||
import { useSidebar } from "~/components/layout/sidebar-provider";
|
import { useSidebar } from "~/components/layout/sidebar-provider";
|
||||||
|
|
||||||
interface FloatingActionBarProps {
|
interface FloatingActionBarProps {
|
||||||
/** Content to display on the left side */
|
|
||||||
leftContent?: React.ReactNode;
|
leftContent?: React.ReactNode;
|
||||||
/** Action buttons to display on the right */
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** Additional className for styling */
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,19 +17,12 @@ export function FloatingActionBar({
|
|||||||
className,
|
className,
|
||||||
}: FloatingActionBarProps) {
|
}: FloatingActionBarProps) {
|
||||||
const { isCollapsed } = useSidebar();
|
const { isCollapsed } = useSidebar();
|
||||||
const { sidebarStyle } = useAppearance();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pb-safe-area-inset-bottom fixed right-0 bottom-4 left-0 z-50 transition-all duration-300 ease-in-out",
|
"pb-safe-area-inset-bottom fixed right-0 bottom-4 left-0 z-50 transition-all duration-300 ease-in-out",
|
||||||
sidebarStyle === "floating"
|
isCollapsed ? "md:left-16" : "md:left-64",
|
||||||
? isCollapsed
|
|
||||||
? "md:left-24"
|
|
||||||
: "md:left-[18rem]"
|
|
||||||
: isCollapsed
|
|
||||||
? "md:left-16"
|
|
||||||
: "md:left-64",
|
|
||||||
"animate-slide-in-bottom",
|
"animate-slide-in-bottom",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useDashboardUser } from "./dashboard-user-context";
|
||||||
|
|
||||||
|
export function OnboardingGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const { needsOnboarding } = useDashboardUser();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const onOnboardingPage = pathname === "/dashboard/onboarding";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (needsOnboarding && !onOnboardingPage) {
|
||||||
|
router.replace("/dashboard/onboarding");
|
||||||
|
}
|
||||||
|
}, [needsOnboarding, onOnboardingPage, router]);
|
||||||
|
|
||||||
|
if (needsOnboarding && !onOnboardingPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
|
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -40,7 +41,7 @@ export function PageHeader({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`animate-fade-in-down mb-6 ${className}`}>
|
<div className={cn("animate-fade-in-down", className)}>
|
||||||
{variant === "large-gradient" || variant === "gradient" ? (
|
{variant === "large-gradient" || variant === "gradient" ? (
|
||||||
<div className="platform-header-surface bg-card text-card-foreground relative overflow-hidden rounded-xl border shadow-sm">
|
<div className="platform-header-surface bg-card text-card-foreground relative overflow-hidden rounded-xl border shadow-sm">
|
||||||
<div className="platform-header-gradient from-primary/5 pointer-events-none absolute inset-0 bg-gradient-to-br via-transparent to-transparent" />
|
<div className="platform-header-gradient from-primary/5 pointer-events-none absolute inset-0 bg-gradient-to-br via-transparent to-transparent" />
|
||||||
@@ -104,8 +105,9 @@ export function DashboardPageHeader({
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
variant="large-gradient"
|
variant="gradient"
|
||||||
className={className}
|
className={cn("mb-0", className)}
|
||||||
|
titleClassName="font-heading text-2xl font-semibold tracking-tight sm:text-3xl"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|||||||
@@ -123,13 +123,13 @@ export function EmptyState({
|
|||||||
return (
|
return (
|
||||||
<div className={cn("py-12 text-center", className)}>
|
<div className={cn("py-12 text-center", className)}>
|
||||||
{icon && (
|
{icon && (
|
||||||
<div className="bg-muted/50 mx-auto mb-4 flex h-16 w-16 items-center justify-center">
|
<div className="bg-muted mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl p-3 [&_svg]:text-muted-foreground">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
|
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-muted-foreground mx-auto mb-4 max-w-sm">
|
<p className="text-muted-foreground mx-auto mb-4 max-w-sm text-sm">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "~/components/ui/tabs";
|
||||||
|
import { dashboardGapClass, dashboardGridClass } from "~/components/layout/dashboard-page";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** Vertical rhythm inside tab panels — matches dashboard page sections. */
|
||||||
|
export const pageTabsPanelClass = dashboardGapClass;
|
||||||
|
|
||||||
|
/** Grid for stacked cards inside a tab panel. */
|
||||||
|
export const pageTabsGridClass = dashboardGridClass;
|
||||||
|
|
||||||
|
type PageTabsProps = React.ComponentPropsWithoutRef<typeof Tabs>;
|
||||||
|
|
||||||
|
export function PageTabs({ className, ...props }: PageTabsProps) {
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
className={cn("flex flex-col", pageTabsPanelClass, className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageTabsListProps = React.ComponentPropsWithoutRef<typeof TabsList>;
|
||||||
|
|
||||||
|
export function PageTabsList({ className, ...props }: PageTabsListProps) {
|
||||||
|
return (
|
||||||
|
<div className="-mx-1 overflow-x-auto px-1 pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
|
<TabsList
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-border/60 inline-flex h-10 w-max min-w-full gap-0.5 rounded-xl border p-1 sm:min-w-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageTabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof TabsTrigger>) {
|
||||||
|
return (
|
||||||
|
<TabsTrigger
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background h-8 rounded-lg px-3.5 text-sm data-[state=active]:shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageTabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof TabsContent>) {
|
||||||
|
return (
|
||||||
|
<TabsContent
|
||||||
|
className={cn(
|
||||||
|
"mt-0 flex flex-col focus-visible:ring-0 focus-visible:outline-none",
|
||||||
|
pageTabsPanelClass,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { authClient } from "~/lib/auth-client";
|
|||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||||
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
|
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
|
||||||
import { useSidebar } from "./sidebar-provider";
|
import { useSidebar } from "./sidebar-provider";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Logo } from "~/components/branding/logo";
|
import { Logo } from "~/components/branding/logo";
|
||||||
@@ -26,8 +26,9 @@ import {
|
|||||||
} from "~/components/ui/dropdown-menu";
|
} from "~/components/ui/dropdown-menu";
|
||||||
import { getGravatarUrl } from "~/lib/gravatar";
|
import { getGravatarUrl } from "~/lib/gravatar";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
|
||||||
import { useAuthSession } from "~/hooks/use-auth-session";
|
import { useAuthSession } from "~/hooks/use-auth-session";
|
||||||
|
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
|
||||||
|
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
@@ -37,8 +38,9 @@ interface SidebarProps {
|
|||||||
export function Sidebar({ mobile, onClose }: SidebarProps) {
|
export function Sidebar({ mobile, onClose }: SidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { data: session, isPending } = useAuthSession();
|
const { data: session, isPending } = useAuthSession();
|
||||||
|
const { isAdmin } = useDashboardUser();
|
||||||
const { isCollapsed, toggleCollapse } = useSidebar();
|
const { isCollapsed, toggleCollapse } = useSidebar();
|
||||||
const { sidebarStyle } = useAppearance();
|
const navSections = getNavigationForUser(isAdmin);
|
||||||
|
|
||||||
// If mobile, always expanded
|
// If mobile, always expanded
|
||||||
const collapsed = mobile ? false : isCollapsed;
|
const collapsed = mobile ? false : isCollapsed;
|
||||||
@@ -72,7 +74,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
|
|||||||
collapsed && "items-center",
|
collapsed && "items-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{navigationConfig.map((section) => (
|
{navSections.map((section) => (
|
||||||
<div key={section.title}>
|
<div key={section.title}>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase">
|
<div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase">
|
||||||
@@ -163,6 +165,8 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ActiveTimerWidget collapsed={collapsed} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-border/50 border-t pt-4",
|
"border-border/50 border-t pt-4",
|
||||||
@@ -265,10 +269,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex",
|
"border-border bg-background fixed top-0 bottom-0 left-0 z-30 hidden flex-col rounded-none border-r shadow-none transition-all duration-300 ease-in-out md:flex",
|
||||||
sidebarStyle === "floating"
|
|
||||||
? "border-border/50 bg-background/80 top-4 bottom-4 left-4 rounded-3xl border shadow-xl backdrop-blur-xl"
|
|
||||||
: "border-border bg-background top-0 bottom-0 left-0 rounded-none border-r shadow-none",
|
|
||||||
isCollapsed ? "w-16" : "w-64",
|
isCollapsed ? "w-16" : "w-64",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { BrowserFrame } from "~/components/marketing/browser-frame";
|
import { BrowserFrame } from "~/components/marketing/browser-frame";
|
||||||
|
import { getAppHost } from "~/lib/app-url";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
const appHost = getAppHost();
|
||||||
|
|
||||||
function MockSidebar({ active }: { active: "dashboard" | "invoices" | "time" }) {
|
function MockSidebar({ active }: { active: "dashboard" | "invoices" | "time" }) {
|
||||||
const items = [
|
const items = [
|
||||||
{ id: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
|
{ id: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
|
||||||
@@ -87,7 +90,7 @@ export function InvoicesScreenshot({ className }: { className?: string }) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserFrame className={className} url="beenvoice.app/dashboard/invoices">
|
<BrowserFrame className={className} url={`${appHost}/dashboard/invoices`}>
|
||||||
<div className="flex min-h-[280px] sm:min-h-[320px]">
|
<div className="flex min-h-[280px] sm:min-h-[320px]">
|
||||||
<MockSidebar active="invoices" />
|
<MockSidebar active="invoices" />
|
||||||
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
||||||
@@ -134,7 +137,7 @@ export function InvoicesScreenshot({ className }: { className?: string }) {
|
|||||||
|
|
||||||
export function TimeClockScreenshot({ className }: { className?: string }) {
|
export function TimeClockScreenshot({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<BrowserFrame className={className} url="beenvoice.app/dashboard/time-clock">
|
<BrowserFrame className={className} url={`${appHost}/dashboard/time-clock`}>
|
||||||
<div className="flex min-h-[260px] sm:min-h-[300px]">
|
<div className="flex min-h-[260px] sm:min-h-[300px]">
|
||||||
<MockSidebar active="time" />
|
<MockSidebar active="time" />
|
||||||
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
||||||
@@ -204,7 +207,7 @@ export function TimeClockScreenshot({ className }: { className?: string }) {
|
|||||||
|
|
||||||
export function DashboardScreenshot({ className }: { className?: string }) {
|
export function DashboardScreenshot({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<BrowserFrame className={className} url="beenvoice.app/dashboard">
|
<BrowserFrame className={className} url={`${appHost}/dashboard`}>
|
||||||
<div className="flex min-h-[260px] sm:min-h-[300px]">
|
<div className="flex min-h-[260px] sm:min-h-[300px]">
|
||||||
<MockSidebar active="dashboard" />
|
<MockSidebar active="dashboard" />
|
||||||
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { getAppHost } from "~/lib/app-url";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
export function BrowserFrame({
|
export function BrowserFrame({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
url = "beenvoice.app/dashboard",
|
url = `${getAppHost()}/dashboard`,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { usePathname } from "next/navigation";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { useAuthSession } from "~/hooks/use-auth-session";
|
import { useAuthSession } from "~/hooks/use-auth-session";
|
||||||
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
|
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
|
||||||
|
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
|
||||||
|
|
||||||
interface SidebarTriggerProps {
|
interface SidebarTriggerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -16,6 +17,8 @@ interface SidebarTriggerProps {
|
|||||||
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isPending } = useAuthSession();
|
const { isPending } = useAuthSession();
|
||||||
|
const { isAdmin } = useDashboardUser();
|
||||||
|
const navSections = getNavigationForUser(isAdmin);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -34,7 +37,7 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
|||||||
<div className="bg-background border-border absolute top-full right-0 left-0 z-40 mt-1 border-t">
|
<div className="bg-background border-border absolute top-full right-0 left-0 z-40 mt-1 border-t">
|
||||||
{/* Navigation content */}
|
{/* Navigation content */}
|
||||||
<nav className="flex flex-col p-4">
|
<nav className="flex flex-col p-4">
|
||||||
{navigationConfig.map((section, sectionIndex) => (
|
{navSections.map((section, sectionIndex) => (
|
||||||
<div
|
<div
|
||||||
key={section.title}
|
key={section.title}
|
||||||
className={sectionIndex > 0 ? "mt-4" : ""}
|
className={sectionIndex > 0 ? "mt-4" : ""}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export function AnimationPreferencesProviderSynced({
|
|||||||
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
|
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
|
||||||
|
|
||||||
if (localIsDefault || differs) {
|
if (localIsDefault || differs) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time server hydration after local storage
|
||||||
performUpdate(
|
performUpdate(
|
||||||
{
|
{
|
||||||
prefersReducedMotion: serverPrefs.prefersReducedMotion,
|
prefersReducedMotion: serverPrefs.prefersReducedMotion,
|
||||||
@@ -187,12 +187,9 @@ export function AnimationPreferencesProviderSynced({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
serverHydratedRef.current = true;
|
serverHydratedRef.current = true;
|
||||||
}, [
|
// One-time hydration from server after local storage is read.
|
||||||
serverPrefs,
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
performUpdate,
|
}, [serverPrefs]);
|
||||||
prefersReducedMotion,
|
|
||||||
animationSpeedMultiplier,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const updatePreferences = useCallback<
|
const updatePreferences = useCallback<
|
||||||
AnimationPreferencesContextValue["updatePreferences"]
|
AnimationPreferencesContextValue["updatePreferences"]
|
||||||
|
|||||||
@@ -1,232 +1,88 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
useCallback,
|
import { defaultColorMode, type ColorMode } from "~/lib/appearance";
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { isHslChannels } from "~/lib/appearance";
|
|
||||||
import type { ColorMode, ColorTheme, FontPreference, InterfaceTheme, RadiusPreference, SidebarStyle } from "~/lib/branding";
|
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import {
|
import {
|
||||||
AppearanceContext,
|
AppearanceContext,
|
||||||
applyAppearance,
|
applyColorMode,
|
||||||
defaultAppearance,
|
defaultAppearance,
|
||||||
readStoredAppearance,
|
readStoredColorMode,
|
||||||
writeStoredAppearance,
|
writeStoredColorMode,
|
||||||
type AppearanceContextValue,
|
type AppearanceContextValue,
|
||||||
type AppearancePatch,
|
type AppearancePatch,
|
||||||
type AppearancePreferences,
|
|
||||||
} from "~/components/providers/appearance-provider";
|
} from "~/components/providers/appearance-provider";
|
||||||
|
|
||||||
type ServerAppearance = {
|
/** Dashboard appearance provider with per-user color mode sync. */
|
||||||
interfaceTheme: InterfaceTheme;
|
|
||||||
bodyFontPreference: FontPreference;
|
|
||||||
headingFontPreference: FontPreference;
|
|
||||||
radiusPreference: RadiusPreference;
|
|
||||||
sidebarStyle: SidebarStyle;
|
|
||||||
theme: ColorMode;
|
|
||||||
colorTheme: ColorTheme;
|
|
||||||
customColor?: string;
|
|
||||||
brandName: string;
|
|
||||||
brandTagline: string;
|
|
||||||
brandLogoText: string;
|
|
||||||
brandIcon: string;
|
|
||||||
pdfTemplate: AppearancePreferences["pdfTemplate"];
|
|
||||||
pdfAccentColor: string;
|
|
||||||
pdfFooterText: string;
|
|
||||||
pdfShowLogo: boolean;
|
|
||||||
pdfShowPageNumbers: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getServerAppearancePatch(
|
|
||||||
serverAppearance: ServerAppearance,
|
|
||||||
): AppearancePatch {
|
|
||||||
return {
|
|
||||||
interfaceTheme: serverAppearance.interfaceTheme,
|
|
||||||
bodyFontPreference: serverAppearance.bodyFontPreference,
|
|
||||||
headingFontPreference: serverAppearance.headingFontPreference,
|
|
||||||
radiusPreference: serverAppearance.radiusPreference,
|
|
||||||
sidebarStyle: serverAppearance.sidebarStyle,
|
|
||||||
colorMode: serverAppearance.theme,
|
|
||||||
colorTheme: serverAppearance.colorTheme,
|
|
||||||
customColor: serverAppearance.customColor,
|
|
||||||
brandName: serverAppearance.brandName,
|
|
||||||
brandTagline: serverAppearance.brandTagline,
|
|
||||||
brandLogoText: serverAppearance.brandLogoText,
|
|
||||||
brandIcon: serverAppearance.brandIcon,
|
|
||||||
pdfTemplate: serverAppearance.pdfTemplate,
|
|
||||||
pdfAccentColor: serverAppearance.pdfAccentColor,
|
|
||||||
pdfFooterText: serverAppearance.pdfFooterText,
|
|
||||||
pdfShowLogo: serverAppearance.pdfShowLogo,
|
|
||||||
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dashboard appearance provider with tRPC theme sync. Must render inside TRPCReactProvider. */
|
|
||||||
export function AppearanceProviderSynced({
|
export function AppearanceProviderSynced({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [appearance, setAppearance] =
|
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
|
||||||
useState<AppearancePreferences>(defaultAppearance);
|
const serverHydratedRef = useRef(false);
|
||||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const updateMutation = api.settings.updateTheme.useMutation({
|
const updateMutation = api.settings.updateColorMode.useMutation({
|
||||||
onSuccess: async () => {
|
|
||||||
await utils.settings.getTheme.invalidate();
|
|
||||||
},
|
|
||||||
onError: () => {
|
onError: () => {
|
||||||
const cachedAppearance = utils.settings.getTheme.getData();
|
const cached = utils.settings.getColorMode.getData();
|
||||||
const fallback = cachedAppearance
|
const fallback = cached?.colorMode ?? defaultColorMode;
|
||||||
? {
|
setColorMode(fallback);
|
||||||
...defaultAppearance,
|
applyColorMode(fallback);
|
||||||
...getServerAppearancePatch(cachedAppearance),
|
writeStoredColorMode(fallback);
|
||||||
}
|
|
||||||
: defaultAppearance;
|
|
||||||
|
|
||||||
setAppearance(fallback);
|
|
||||||
applyAppearance(fallback);
|
|
||||||
writeStoredAppearance(fallback);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistAppearance = useCallback(
|
const { data: serverColorMode } = api.settings.getColorMode.useQuery(
|
||||||
(patch: AppearancePatch) => {
|
undefined,
|
||||||
if (
|
{
|
||||||
patch.customColor !== undefined &&
|
retry: false,
|
||||||
!isHslChannels(patch.customColor)
|
refetchOnWindowFocus: false,
|
||||||
) {
|
staleTime: 60_000,
|
||||||
return;
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
updateMutation.mutate({
|
useEffect(() => {
|
||||||
interfaceTheme: patch.interfaceTheme,
|
const stored = readStoredColorMode();
|
||||||
bodyFontPreference: patch.bodyFontPreference,
|
if (stored) {
|
||||||
headingFontPreference: patch.headingFontPreference,
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
radiusPreference: patch.radiusPreference,
|
setColorMode(stored);
|
||||||
sidebarStyle: patch.sidebarStyle,
|
}
|
||||||
theme: patch.colorMode,
|
}, []);
|
||||||
colorTheme: patch.colorTheme,
|
|
||||||
customColor: patch.customColor,
|
useEffect(() => {
|
||||||
brandName: patch.brandName,
|
if (!serverColorMode?.colorMode) return;
|
||||||
brandTagline: patch.brandTagline,
|
if (serverHydratedRef.current) return;
|
||||||
brandLogoText: patch.brandLogoText,
|
|
||||||
brandIcon: patch.brandIcon,
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
pdfTemplate: patch.pdfTemplate,
|
setColorMode(serverColorMode.colorMode);
|
||||||
pdfAccentColor: patch.pdfAccentColor,
|
serverHydratedRef.current = true;
|
||||||
pdfFooterText: patch.pdfFooterText,
|
}, [serverColorMode?.colorMode]);
|
||||||
pdfShowLogo: patch.pdfShowLogo,
|
|
||||||
pdfShowPageNumbers: patch.pdfShowPageNumbers,
|
useEffect(() => {
|
||||||
});
|
applyColorMode(colorMode);
|
||||||
|
writeStoredColorMode(colorMode);
|
||||||
|
}, [colorMode]);
|
||||||
|
|
||||||
|
const updateAppearance = useCallback(
|
||||||
|
(patch: AppearancePatch) => {
|
||||||
|
if (!patch.colorMode) return;
|
||||||
|
|
||||||
|
setColorMode(patch.colorMode);
|
||||||
|
applyColorMode(patch.colorMode);
|
||||||
|
writeStoredColorMode(patch.colorMode);
|
||||||
|
updateMutation.mutate({ colorMode: patch.colorMode });
|
||||||
},
|
},
|
||||||
[updateMutation],
|
[updateMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
|
|
||||||
retry: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
staleTime: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const storedAppearance = readStoredAppearance();
|
|
||||||
if (!storedAppearance) return;
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!serverAppearance) return;
|
|
||||||
const next = getServerAppearancePatch(serverAppearance);
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setAppearance((prev) => ({ ...prev, ...next }));
|
|
||||||
}, [serverAppearance]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
applyAppearance(appearance);
|
|
||||||
writeStoredAppearance(appearance);
|
|
||||||
}, [appearance]);
|
|
||||||
|
|
||||||
const updateAppearance = useCallback(
|
|
||||||
(patch: AppearancePatch) => {
|
|
||||||
if (debounceTimerRef.current) {
|
|
||||||
clearTimeout(debounceTimerRef.current);
|
|
||||||
debounceTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (Object.keys(pendingDebouncedPatchRef.current).length > 0) {
|
|
||||||
persistAppearance(pendingDebouncedPatchRef.current);
|
|
||||||
pendingDebouncedPatchRef.current = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
setAppearance((prev) => {
|
|
||||||
const next = { ...prev, ...patch };
|
|
||||||
applyAppearance(next);
|
|
||||||
writeStoredAppearance(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
persistAppearance(patch);
|
|
||||||
},
|
|
||||||
[persistAppearance],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateAppearanceDebounced = useCallback(
|
|
||||||
(patch: AppearancePatch) => {
|
|
||||||
pendingDebouncedPatchRef.current = {
|
|
||||||
...pendingDebouncedPatchRef.current,
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
|
|
||||||
setAppearance((prev) => {
|
|
||||||
const next = { ...prev, ...patch };
|
|
||||||
applyAppearance(next);
|
|
||||||
writeStoredAppearance(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (debounceTimerRef.current) {
|
|
||||||
clearTimeout(debounceTimerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
debounceTimerRef.current = setTimeout(() => {
|
|
||||||
persistAppearance(pendingDebouncedPatchRef.current);
|
|
||||||
pendingDebouncedPatchRef.current = {};
|
|
||||||
debounceTimerRef.current = null;
|
|
||||||
}, 500);
|
|
||||||
},
|
|
||||||
[persistAppearance],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
if (debounceTimerRef.current) {
|
|
||||||
clearTimeout(debounceTimerRef.current);
|
|
||||||
}
|
|
||||||
pendingDebouncedPatchRef.current = {};
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const value = useMemo<AppearanceContextValue>(
|
const value = useMemo<AppearanceContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
...appearance,
|
...defaultAppearance,
|
||||||
|
colorMode,
|
||||||
updateAppearance,
|
updateAppearance,
|
||||||
updateAppearanceDebounced,
|
|
||||||
isUpdating: updateMutation.isPending,
|
isUpdating: updateMutation.isPending,
|
||||||
}),
|
}),
|
||||||
[
|
[colorMode, updateAppearance, updateMutation.isPending],
|
||||||
appearance,
|
|
||||||
updateAppearance,
|
|
||||||
updateAppearanceDebounced,
|
|
||||||
updateMutation.isPending,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,181 +8,53 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import { defaultColorMode, isColorMode, type ColorMode } from "~/lib/appearance";
|
||||||
fallbackAppearance,
|
|
||||||
isColorMode,
|
|
||||||
isColorTheme,
|
|
||||||
isFontPreference,
|
|
||||||
isHslChannels,
|
|
||||||
isInterfaceTheme,
|
|
||||||
isPdfTemplate,
|
|
||||||
isRadiusPreference,
|
|
||||||
isSidebarStyle,
|
|
||||||
type PdfTemplate,
|
|
||||||
} from "~/lib/appearance";
|
|
||||||
import {
|
|
||||||
defaultBodyFontPreference,
|
|
||||||
defaultHeadingFontPreference,
|
|
||||||
defaultInterfaceTheme,
|
|
||||||
defaultRadiusPreference,
|
|
||||||
defaultSidebarStyle,
|
|
||||||
brand as defaultBrand,
|
|
||||||
type ColorMode,
|
|
||||||
type ColorTheme,
|
|
||||||
type FontPreference,
|
|
||||||
type InterfaceTheme,
|
|
||||||
type RadiusPreference,
|
|
||||||
type SidebarStyle,
|
|
||||||
} from "~/lib/branding";
|
|
||||||
|
|
||||||
export type AppearancePreferences = {
|
export type AppearancePreferences = {
|
||||||
interfaceTheme: InterfaceTheme;
|
|
||||||
bodyFontPreference: FontPreference;
|
|
||||||
headingFontPreference: FontPreference;
|
|
||||||
radiusPreference: RadiusPreference;
|
|
||||||
sidebarStyle: SidebarStyle;
|
|
||||||
colorMode: ColorMode;
|
colorMode: ColorMode;
|
||||||
colorTheme: ColorTheme;
|
|
||||||
customColor?: string;
|
|
||||||
brandName: string;
|
|
||||||
brandTagline: string;
|
|
||||||
brandLogoText: string;
|
|
||||||
brandIcon: string;
|
|
||||||
pdfTemplate: PdfTemplate;
|
|
||||||
pdfAccentColor: string;
|
|
||||||
pdfFooterText: string;
|
|
||||||
pdfShowLogo: boolean;
|
|
||||||
pdfShowPageNumbers: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppearancePatch = Partial<AppearancePreferences>;
|
export type AppearancePatch = Partial<AppearancePreferences>;
|
||||||
|
|
||||||
export type AppearanceContextValue = AppearancePreferences & {
|
export type AppearanceContextValue = AppearancePreferences & {
|
||||||
updateAppearance: (patch: AppearancePatch) => void;
|
updateAppearance: (patch: AppearancePatch) => void;
|
||||||
updateAppearanceDebounced: (patch: AppearancePatch) => void;
|
|
||||||
isUpdating: boolean;
|
isUpdating: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STORAGE_KEY = "bv.appearance";
|
export const STORAGE_KEY = "bv.appearance";
|
||||||
|
|
||||||
export const defaultAppearance: AppearancePreferences = {
|
export const defaultAppearance: AppearancePreferences = {
|
||||||
interfaceTheme: defaultInterfaceTheme,
|
colorMode: defaultColorMode,
|
||||||
bodyFontPreference: defaultBodyFontPreference,
|
|
||||||
headingFontPreference: defaultHeadingFontPreference,
|
|
||||||
radiusPreference: defaultRadiusPreference,
|
|
||||||
sidebarStyle: defaultSidebarStyle,
|
|
||||||
colorMode: fallbackAppearance.colorMode,
|
|
||||||
colorTheme: fallbackAppearance.colorTheme,
|
|
||||||
brandName: defaultBrand.name,
|
|
||||||
brandTagline: defaultBrand.tagline,
|
|
||||||
brandLogoText: defaultBrand.logoText,
|
|
||||||
brandIcon: defaultBrand.icon,
|
|
||||||
pdfTemplate: fallbackAppearance.pdfTemplate,
|
|
||||||
pdfAccentColor: fallbackAppearance.pdfAccentColor,
|
|
||||||
pdfFooterText: fallbackAppearance.pdfFooterText,
|
|
||||||
pdfShowLogo: fallbackAppearance.pdfShowLogo,
|
|
||||||
pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AppearanceContext =
|
export const AppearanceContext =
|
||||||
createContext<AppearanceContextValue | null>(null);
|
createContext<AppearanceContextValue | null>(null);
|
||||||
|
|
||||||
export function readStoredAppearance(): Partial<AppearancePreferences> | null {
|
export function readStoredColorMode(): ColorMode | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
const parsed = JSON.parse(raw) as { colorMode?: unknown };
|
||||||
return {
|
return isColorMode(parsed.colorMode) ? parsed.colorMode : null;
|
||||||
interfaceTheme: isInterfaceTheme(parsed.interfaceTheme)
|
|
||||||
? parsed.interfaceTheme
|
|
||||||
: undefined,
|
|
||||||
bodyFontPreference: isFontPreference(parsed.bodyFontPreference)
|
|
||||||
? parsed.bodyFontPreference
|
|
||||||
: isFontPreference(parsed.fontPreference)
|
|
||||||
? parsed.fontPreference
|
|
||||||
: undefined,
|
|
||||||
headingFontPreference: isFontPreference(parsed.headingFontPreference)
|
|
||||||
? parsed.headingFontPreference
|
|
||||||
: isFontPreference(parsed.fontPreference)
|
|
||||||
? parsed.fontPreference
|
|
||||||
: undefined,
|
|
||||||
radiusPreference: isRadiusPreference(parsed.radiusPreference)
|
|
||||||
? parsed.radiusPreference
|
|
||||||
: undefined,
|
|
||||||
sidebarStyle: isSidebarStyle(parsed.sidebarStyle)
|
|
||||||
? parsed.sidebarStyle
|
|
||||||
: undefined,
|
|
||||||
colorMode: isColorMode(parsed.colorMode) ? parsed.colorMode : undefined,
|
|
||||||
colorTheme: isColorTheme(parsed.colorTheme)
|
|
||||||
? parsed.colorTheme
|
|
||||||
: undefined,
|
|
||||||
customColor: isHslChannels(parsed.customColor)
|
|
||||||
? parsed.customColor
|
|
||||||
: undefined,
|
|
||||||
brandName:
|
|
||||||
typeof parsed.brandName === "string" ? parsed.brandName : undefined,
|
|
||||||
brandTagline:
|
|
||||||
typeof parsed.brandTagline === "string"
|
|
||||||
? parsed.brandTagline
|
|
||||||
: undefined,
|
|
||||||
brandLogoText:
|
|
||||||
typeof parsed.brandLogoText === "string"
|
|
||||||
? parsed.brandLogoText
|
|
||||||
: undefined,
|
|
||||||
brandIcon:
|
|
||||||
typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined,
|
|
||||||
pdfTemplate: isPdfTemplate(parsed.pdfTemplate)
|
|
||||||
? parsed.pdfTemplate
|
|
||||||
: undefined,
|
|
||||||
pdfAccentColor:
|
|
||||||
typeof parsed.pdfAccentColor === "string"
|
|
||||||
? parsed.pdfAccentColor
|
|
||||||
: undefined,
|
|
||||||
pdfFooterText:
|
|
||||||
typeof parsed.pdfFooterText === "string"
|
|
||||||
? parsed.pdfFooterText
|
|
||||||
: undefined,
|
|
||||||
pdfShowLogo:
|
|
||||||
typeof parsed.pdfShowLogo === "boolean"
|
|
||||||
? parsed.pdfShowLogo
|
|
||||||
: undefined,
|
|
||||||
pdfShowPageNumbers:
|
|
||||||
typeof parsed.pdfShowPageNumbers === "boolean"
|
|
||||||
? parsed.pdfShowPageNumbers
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeStoredAppearance(prefs: AppearancePreferences) {
|
export function writeStoredColorMode(colorMode: ColorMode) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ colorMode }));
|
||||||
} catch {
|
} catch {
|
||||||
// Storage can be unavailable in private browsing or locked-down contexts.
|
// Storage can be unavailable in private browsing or locked-down contexts.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyAppearance(prefs: AppearancePreferences) {
|
export function applyColorMode(colorMode: ColorMode) {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.dataset.interfaceTheme = prefs.interfaceTheme;
|
root.dataset.colorMode = colorMode;
|
||||||
root.dataset.bodyFont = prefs.bodyFontPreference;
|
root.classList.toggle("dark", colorMode === "dark");
|
||||||
root.dataset.headingFont = prefs.headingFontPreference;
|
|
||||||
root.dataset.radius = prefs.radiusPreference;
|
|
||||||
root.dataset.sidebarStyle = prefs.sidebarStyle;
|
|
||||||
root.dataset.colorMode = prefs.colorMode;
|
|
||||||
root.dataset.colorTheme = prefs.colorTheme;
|
|
||||||
|
|
||||||
root.classList.toggle("dark", prefs.colorMode === "dark");
|
|
||||||
|
|
||||||
if (prefs.customColor) {
|
|
||||||
root.style.setProperty("--custom-primary", prefs.customColor);
|
|
||||||
} else {
|
|
||||||
root.style.removeProperty("--custom-primary");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Local-only appearance provider for marketing and auth pages (no tRPC). */
|
/** Local-only appearance provider for marketing and auth pages (no tRPC). */
|
||||||
@@ -191,48 +63,36 @@ export function AppearanceProvider({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [appearance, setAppearance] =
|
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
|
||||||
useState<AppearancePreferences>(defaultAppearance);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedAppearance = readStoredAppearance();
|
const stored = readStoredColorMode();
|
||||||
if (!storedAppearance) return;
|
if (stored) {
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
|
setColorMode(stored);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyAppearance(appearance);
|
applyColorMode(colorMode);
|
||||||
writeStoredAppearance(appearance);
|
writeStoredColorMode(colorMode);
|
||||||
}, [appearance]);
|
}, [colorMode]);
|
||||||
|
|
||||||
const updateAppearance = useCallback((patch: AppearancePatch) => {
|
const updateAppearance = useCallback((patch: AppearancePatch) => {
|
||||||
setAppearance((prev) => {
|
if (patch.colorMode) {
|
||||||
const next = { ...prev, ...patch };
|
setColorMode(patch.colorMode);
|
||||||
applyAppearance(next);
|
applyColorMode(patch.colorMode);
|
||||||
writeStoredAppearance(next);
|
writeStoredColorMode(patch.colorMode);
|
||||||
return next;
|
}
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateAppearanceDebounced = useCallback((patch: AppearancePatch) => {
|
|
||||||
setAppearance((prev) => {
|
|
||||||
const next = { ...prev, ...patch };
|
|
||||||
applyAppearance(next);
|
|
||||||
writeStoredAppearance(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo<AppearanceContextValue>(
|
const value = useMemo<AppearanceContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
...appearance,
|
colorMode,
|
||||||
updateAppearance,
|
updateAppearance,
|
||||||
updateAppearanceDebounced,
|
|
||||||
isUpdating: false,
|
isUpdating: false,
|
||||||
}),
|
}),
|
||||||
[appearance, updateAppearance, updateAppearanceDebounced],
|
[colorMode, updateAppearance],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "~/components/ui/collapsible";
|
} from "~/components/ui/collapsible";
|
||||||
import { ChevronDown, Clock, ExternalLink, Play, Square } from "lucide-react";
|
import { ChevronDown, Clock, Play, Square } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
describeClockOutOutcome,
|
describeClockOutOutcome,
|
||||||
formatElapsedSeconds,
|
formatElapsedSeconds,
|
||||||
|
formatRunningTimerLabel,
|
||||||
resolveClockDescription,
|
resolveClockDescription,
|
||||||
resolveEffectiveHourlyRate,
|
resolveEffectiveHourlyRate,
|
||||||
startedAtFromMinutesAgo,
|
startedAtFromMinutesAgo,
|
||||||
@@ -52,6 +53,19 @@ function invoiceLabel(inv: {
|
|||||||
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
|
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function entryHref(entry: {
|
||||||
|
invoiceId: string | null;
|
||||||
|
clientId: string | null;
|
||||||
|
invoice?: { id: string } | null;
|
||||||
|
client?: { id: string } | null;
|
||||||
|
}): string | null {
|
||||||
|
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
|
||||||
|
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
|
||||||
|
const clientId = entry.clientId ?? entry.client?.id;
|
||||||
|
if (clientId) return `/dashboard/clients/${clientId}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function ClientChip({
|
function ClientChip({
|
||||||
label,
|
label,
|
||||||
active,
|
active,
|
||||||
@@ -278,8 +292,7 @@ export function TimeClockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const displayRate = running ? (running.rate ?? 0) : rate;
|
const displayRate = running ? (running.rate ?? 0) : rate;
|
||||||
const runningTitle =
|
const runningTitle = formatRunningTimerLabel(running?.description);
|
||||||
running?.description?.trim() ?? resolveClockDescription("");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
<div className={compact ? "space-y-4" : "space-y-6"}>
|
||||||
@@ -478,7 +491,11 @@ export function TimeClockPanel({
|
|||||||
id="clock-stop-note"
|
id="clock-stop-note"
|
||||||
value={stopNote}
|
value={stopNote}
|
||||||
onChange={(e) => setStopNote(e.target.value)}
|
onChange={(e) => setStopNote(e.target.value)}
|
||||||
placeholder={running?.description || "Update description when you stop"}
|
placeholder={
|
||||||
|
running?.description?.trim()
|
||||||
|
? running.description
|
||||||
|
: "Update description when you stop"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -517,19 +534,21 @@ export function TimeClockPanel({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Today's entries</CardTitle>
|
<CardTitle className="text-base">Today's entries</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="divide-y">
|
<CardContent>
|
||||||
{todayEntries
|
{todayEntries
|
||||||
.filter((e) => e.endedAt)
|
.filter((e) => e.endedAt)
|
||||||
.map((entry) => (
|
.map((entry, index, entries) => {
|
||||||
<div
|
const href = entryHref(entry);
|
||||||
key={entry.id}
|
const isLast = index === entries.length - 1;
|
||||||
className="flex items-start justify-between gap-4 py-3 first:pt-0 last:pb-0"
|
const rowClassName = cn(
|
||||||
>
|
"flex items-start justify-between gap-4 py-3",
|
||||||
|
!isLast && "border-border border-b",
|
||||||
|
);
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{entry.description || (
|
{formatRunningTimerLabel(entry.description)}
|
||||||
<span className="text-muted-foreground italic">No description</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{entry.client?.name ?? "No client"}
|
{entry.client?.name ?? "No client"}
|
||||||
@@ -546,20 +565,34 @@ export function TimeClockPanel({
|
|||||||
<p className="text-muted-foreground">${entry.rate}/hr</p>
|
<p className="text-muted-foreground">${entry.rate}/hr</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={entry.id}
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
rowClassName,
|
||||||
|
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className={rowClassName}>
|
||||||
|
{content}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{compact ? (
|
|
||||||
<Button variant="link" className="h-auto p-0" asChild>
|
|
||||||
<Link href="/dashboard/time-clock">
|
|
||||||
Open full time clock
|
|
||||||
<ExternalLink className="ml-1 h-3.5 w-3.5" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,18 +14,23 @@ import {
|
|||||||
} from "~/components/ui/popover";
|
} from "~/components/ui/popover";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
};
|
||||||
|
|
||||||
function formatDate(date: Date | undefined) {
|
function formatDate(date: Date | undefined) {
|
||||||
if (!date) {
|
if (!date) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return date.toLocaleDateString("en-US", {
|
return date.toLocaleDateString("en-US", DATE_FORMAT_OPTIONS);
|
||||||
day: "2-digit",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Longest month name in en-US long format (September 30, 2026).
|
||||||
|
const LONGEST_FORMATTED_DATE = formatDate(new Date(2026, 8, 30));
|
||||||
|
|
||||||
interface DatePickerProps {
|
interface DatePickerProps {
|
||||||
date?: Date;
|
date?: Date;
|
||||||
onDateChange: (date: Date | undefined) => void;
|
onDateChange: (date: Date | undefined) => void;
|
||||||
@@ -57,13 +62,7 @@ export function DatePicker({
|
|||||||
lg: "h-10 text-sm",
|
lg: "h-10 text-sm",
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputWidthClass = className?.includes("w-full")
|
const wantsFullWidth = className?.includes("w-full");
|
||||||
? "w-full"
|
|
||||||
: className?.includes("w-32") ||
|
|
||||||
className?.includes("w-28") ||
|
|
||||||
className?.includes("w-36")
|
|
||||||
? className
|
|
||||||
: "w-full md:w-32 md:min-w-32";
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
|
||||||
@@ -72,16 +71,31 @@ export function DatePicker({
|
|||||||
}, [date]);
|
}, [date]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative flex gap-2", inputWidthClass, className)}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative min-w-max",
|
||||||
|
wantsFullWidth ? "w-full" : "w-auto shrink-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"invisible block whitespace-nowrap px-3 pr-10",
|
||||||
|
sizeClasses[size],
|
||||||
|
inputClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{LONGEST_FORMATTED_DATE}
|
||||||
|
</span>
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background pr-10",
|
"bg-background absolute inset-0 w-full pr-10 tabular-nums",
|
||||||
sizeClasses[size],
|
sizeClasses[size],
|
||||||
"w-full",
|
|
||||||
inputClassName,
|
inputClassName,
|
||||||
)}
|
)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ function SheetContent({
|
|||||||
side === "left" &&
|
side === "left" &&
|
||||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
side === "top" &&
|
side === "top" &&
|
||||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto rounded-b-xl border-b",
|
||||||
side === "bottom" &&
|
side === "bottom" &&
|
||||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto rounded-t-xl border-t",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const Tabs = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Root
|
<TabsPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("flex flex-col gap-1", className)}
|
className={cn("flex flex-col gap-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -54,7 +54,7 @@ const TabsContent = React.forwardRef<
|
|||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
"ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
-34
@@ -56,31 +56,6 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_BRAND_TAGLINE: z.string().optional(),
|
NEXT_PUBLIC_BRAND_TAGLINE: z.string().optional(),
|
||||||
NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(),
|
NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(),
|
||||||
NEXT_PUBLIC_BRAND_ICON: z.string().optional(),
|
NEXT_PUBLIC_BRAND_ICON: z.string().optional(),
|
||||||
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME: z
|
|
||||||
.enum([
|
|
||||||
"beenvoice",
|
|
||||||
"frutiger",
|
|
||||||
"frutiger-aero",
|
|
||||||
"shadcn",
|
|
||||||
"minimal",
|
|
||||||
"editorial",
|
|
||||||
])
|
|
||||||
.optional(),
|
|
||||||
NEXT_PUBLIC_DEFAULT_FONT: z
|
|
||||||
.enum(["brand", "frutiger", "platform", "inter", "serif"])
|
|
||||||
.optional(),
|
|
||||||
NEXT_PUBLIC_DEFAULT_BODY_FONT: z
|
|
||||||
.enum(["brand", "frutiger", "platform", "inter", "serif"])
|
|
||||||
.optional(),
|
|
||||||
NEXT_PUBLIC_DEFAULT_HEADING_FONT: z
|
|
||||||
.enum(["brand", "frutiger", "platform", "inter", "serif"])
|
|
||||||
.optional(),
|
|
||||||
NEXT_PUBLIC_DEFAULT_RADIUS: z
|
|
||||||
.enum(["none", "sm", "md", "lg", "xl"])
|
|
||||||
.optional(),
|
|
||||||
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: z
|
|
||||||
.enum(["floating", "docked"])
|
|
||||||
.optional(),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,15 +84,6 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_BRAND_TAGLINE: process.env.NEXT_PUBLIC_BRAND_TAGLINE,
|
NEXT_PUBLIC_BRAND_TAGLINE: process.env.NEXT_PUBLIC_BRAND_TAGLINE,
|
||||||
NEXT_PUBLIC_BRAND_LOGO_TEXT: process.env.NEXT_PUBLIC_BRAND_LOGO_TEXT,
|
NEXT_PUBLIC_BRAND_LOGO_TEXT: process.env.NEXT_PUBLIC_BRAND_LOGO_TEXT,
|
||||||
NEXT_PUBLIC_BRAND_ICON: process.env.NEXT_PUBLIC_BRAND_ICON,
|
NEXT_PUBLIC_BRAND_ICON: process.env.NEXT_PUBLIC_BRAND_ICON,
|
||||||
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME:
|
|
||||||
process.env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME,
|
|
||||||
NEXT_PUBLIC_DEFAULT_FONT: process.env.NEXT_PUBLIC_DEFAULT_FONT,
|
|
||||||
NEXT_PUBLIC_DEFAULT_BODY_FONT: process.env.NEXT_PUBLIC_DEFAULT_BODY_FONT,
|
|
||||||
NEXT_PUBLIC_DEFAULT_HEADING_FONT:
|
|
||||||
process.env.NEXT_PUBLIC_DEFAULT_HEADING_FONT,
|
|
||||||
NEXT_PUBLIC_DEFAULT_RADIUS: process.env.NEXT_PUBLIC_DEFAULT_RADIUS,
|
|
||||||
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE:
|
|
||||||
process.env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE,
|
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const APP_EMAIL_DOMAIN = "beenvoice.app";
|
||||||
|
|
||||||
|
export const PRIVACY_EMAIL = `privacy@${APP_EMAIL_DOMAIN}`;
|
||||||
|
export const LEGAL_EMAIL = `legal@${APP_EMAIL_DOMAIN}`;
|
||||||
|
export const SUPPORT_EMAIL = `support@${APP_EMAIL_DOMAIN}`;
|
||||||
|
export const NOREPLY_EMAIL = `noreply@${APP_EMAIL_DOMAIN}`;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/** Public app origin (no trailing slash). */
|
||||||
|
export function getAppUrl(): string {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return window.location.origin.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromEnv = process.env.NEXT_PUBLIC_APP_URL?.trim();
|
||||||
|
if (fromEnv) return fromEnv.replace(/\/$/, "");
|
||||||
|
|
||||||
|
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hostname for display (e.g. marketing browser chrome). */
|
||||||
|
export function getAppHost(): string {
|
||||||
|
try {
|
||||||
|
return new URL(getAppUrl()).host;
|
||||||
|
} catch {
|
||||||
|
return "beenvoice.app";
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-102
@@ -1,126 +1,28 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const interfaceThemeValues = [
|
|
||||||
"beenvoice",
|
|
||||||
"frutiger",
|
|
||||||
"frutiger-aero",
|
|
||||||
"shadcn",
|
|
||||||
"minimal",
|
|
||||||
"editorial",
|
|
||||||
] as const;
|
|
||||||
export const fontPreferenceValues = [
|
|
||||||
"brand",
|
|
||||||
"frutiger",
|
|
||||||
"platform",
|
|
||||||
"inter",
|
|
||||||
"serif",
|
|
||||||
] as const;
|
|
||||||
export const radiusPreferenceValues = ["none", "sm", "md", "lg", "xl"] as const;
|
|
||||||
export const sidebarStyleValues = ["floating", "docked"] as const;
|
|
||||||
export const colorModeValues = ["light", "dark", "system"] as const;
|
export const colorModeValues = ["light", "dark", "system"] as const;
|
||||||
export const colorThemeValues = [
|
|
||||||
"slate",
|
|
||||||
"blue",
|
|
||||||
"green",
|
|
||||||
"rose",
|
|
||||||
"orange",
|
|
||||||
"custom",
|
|
||||||
] as const;
|
|
||||||
export const pdfTemplateValues = ["classic", "minimal"] as const;
|
export const pdfTemplateValues = ["classic", "minimal"] as const;
|
||||||
|
|
||||||
export const interfaceThemeSchema = z.enum(interfaceThemeValues);
|
|
||||||
export const fontPreferenceSchema = z.enum(fontPreferenceValues);
|
|
||||||
export const radiusPreferenceSchema = z.enum(radiusPreferenceValues);
|
|
||||||
export const sidebarStyleSchema = z.enum(sidebarStyleValues);
|
|
||||||
export const colorModeSchema = z.enum(colorModeValues);
|
export const colorModeSchema = z.enum(colorModeValues);
|
||||||
export const colorThemeSchema = z.enum(colorThemeValues);
|
|
||||||
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
|
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
|
||||||
|
|
||||||
export const hslChannelsSchema = z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.regex(
|
|
||||||
/^(?:360(?:\.0)?|3[0-5]\d(?:\.\d)?|[12]?\d?\d(?:\.\d)?)\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%$/,
|
|
||||||
"Use HSL channels like 142.1 76.2% 36.3%",
|
|
||||||
);
|
|
||||||
|
|
||||||
export type InterfaceTheme = z.infer<typeof interfaceThemeSchema>;
|
|
||||||
export type FontPreference = z.infer<typeof fontPreferenceSchema>;
|
|
||||||
export type RadiusPreference = z.infer<typeof radiusPreferenceSchema>;
|
|
||||||
export type SidebarStyle = z.infer<typeof sidebarStyleSchema>;
|
|
||||||
export type ColorMode = z.infer<typeof colorModeSchema>;
|
export type ColorMode = z.infer<typeof colorModeSchema>;
|
||||||
export type ColorTheme = z.infer<typeof colorThemeSchema>;
|
|
||||||
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
|
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
|
||||||
|
|
||||||
export const fallbackAppearance = {
|
export const defaultColorMode: ColorMode = "system";
|
||||||
interfaceTheme: "beenvoice",
|
|
||||||
fontPreference: "brand",
|
export const defaultPdfSettings = {
|
||||||
bodyFontPreference: "brand",
|
pdfTemplate: "classic" as PdfTemplate,
|
||||||
headingFontPreference: "brand",
|
|
||||||
radiusPreference: "xl",
|
|
||||||
sidebarStyle: "floating",
|
|
||||||
colorMode: "system",
|
|
||||||
colorTheme: "slate",
|
|
||||||
customColor: undefined,
|
|
||||||
brandName: "beenvoice",
|
|
||||||
brandTagline:
|
|
||||||
"Simple and efficient invoicing for freelancers and small businesses",
|
|
||||||
brandLogoText: "beenvoice",
|
|
||||||
brandIcon: "$",
|
|
||||||
pdfTemplate: "classic",
|
|
||||||
pdfAccentColor: "#111827",
|
pdfAccentColor: "#111827",
|
||||||
pdfFooterText: "Professional Invoicing",
|
pdfFooterText: "Professional Invoicing",
|
||||||
pdfShowLogo: true,
|
pdfShowLogo: true,
|
||||||
pdfShowPageNumbers: true,
|
pdfShowPageNumbers: true,
|
||||||
} satisfies {
|
|
||||||
interfaceTheme: InterfaceTheme;
|
|
||||||
fontPreference: FontPreference;
|
|
||||||
bodyFontPreference: FontPreference;
|
|
||||||
headingFontPreference: FontPreference;
|
|
||||||
radiusPreference: RadiusPreference;
|
|
||||||
sidebarStyle: SidebarStyle;
|
|
||||||
colorMode: ColorMode;
|
|
||||||
colorTheme: ColorTheme;
|
|
||||||
customColor?: string;
|
|
||||||
brandName: string;
|
|
||||||
brandTagline: string;
|
|
||||||
brandLogoText: string;
|
|
||||||
brandIcon: string;
|
|
||||||
pdfTemplate: PdfTemplate;
|
|
||||||
pdfAccentColor: string;
|
|
||||||
pdfFooterText: string;
|
|
||||||
pdfShowLogo: boolean;
|
|
||||||
pdfShowPageNumbers: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isInterfaceTheme(value: unknown): value is InterfaceTheme {
|
|
||||||
return interfaceThemeSchema.safeParse(value).success;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isFontPreference(value: unknown): value is FontPreference {
|
|
||||||
return fontPreferenceSchema.safeParse(value).success;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isColorMode(value: unknown): value is ColorMode {
|
export function isColorMode(value: unknown): value is ColorMode {
|
||||||
return colorModeSchema.safeParse(value).success;
|
return colorModeSchema.safeParse(value).success;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isColorTheme(value: unknown): value is ColorTheme {
|
|
||||||
return colorThemeSchema.safeParse(value).success;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRadiusPreference(value: unknown): value is RadiusPreference {
|
|
||||||
return radiusPreferenceSchema.safeParse(value).success;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSidebarStyle(value: unknown): value is SidebarStyle {
|
|
||||||
return sidebarStyleSchema.safeParse(value).success;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPdfTemplate(value: unknown): value is PdfTemplate {
|
export function isPdfTemplate(value: unknown): value is PdfTemplate {
|
||||||
return pdfTemplateSchema.safeParse(value).success;
|
return pdfTemplateSchema.safeParse(value).success;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isHslChannels(value: unknown): value is string {
|
|
||||||
return hslChannelsSchema.safeParse(value).success;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,14 +3,10 @@
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||||
|
|
||||||
function resolveAuthBaseUrl(): string | undefined {
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
// Always use the current origin in the browser so dev works on any port
|
|
||||||
// (e.g. 3002 when 3000 is taken), without rebuilding for NEXT_PUBLIC_APP_URL.
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
return window.location.origin;
|
|
||||||
}
|
|
||||||
|
|
||||||
return process.env.NEXT_PUBLIC_APP_URL;
|
function resolveAuthBaseUrl(): string | undefined {
|
||||||
|
return getAppUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|||||||
import { nextCookies } from "better-auth/next-js";
|
import { nextCookies } from "better-auth/next-js";
|
||||||
import { genericOAuth } from "better-auth/plugins";
|
import { genericOAuth } from "better-auth/plugins";
|
||||||
import { envBoolean } from "~/lib/env-boolean";
|
import { envBoolean } from "~/lib/env-boolean";
|
||||||
|
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import * as schema from "~/server/db/schema";
|
import * as schema from "~/server/db/schema";
|
||||||
|
|
||||||
@@ -48,6 +49,18 @@ export const auth = betterAuth({
|
|||||||
verification: schema.verificationTokens,
|
verification: schema.verificationTokens,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
databaseHooks: {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
after: async (user) => {
|
||||||
|
if (isDemoUser(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await promoteFirstRealUserIfNeeded(user.id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
trustedOrigins: async (request) => {
|
trustedOrigins: async (request) => {
|
||||||
const origins = [...staticTrustedOrigins];
|
const origins = [...staticTrustedOrigins];
|
||||||
|
|
||||||
|
|||||||
+39
-276
@@ -1,292 +1,55 @@
|
|||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import {
|
import { type ColorMode } from "~/lib/appearance";
|
||||||
fallbackAppearance,
|
|
||||||
type ColorMode,
|
|
||||||
type ColorTheme,
|
|
||||||
type FontPreference,
|
|
||||||
type InterfaceTheme,
|
|
||||||
type PdfTemplate,
|
|
||||||
type RadiusPreference,
|
|
||||||
type SidebarStyle,
|
|
||||||
} from "~/lib/appearance";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
ColorMode,
|
|
||||||
ColorTheme,
|
|
||||||
FontPreference,
|
|
||||||
InterfaceTheme,
|
|
||||||
PdfTemplate,
|
|
||||||
RadiusPreference,
|
|
||||||
SidebarStyle,
|
|
||||||
} from "~/lib/appearance";
|
|
||||||
|
|
||||||
|
export type { ColorMode, PdfTemplate } from "~/lib/appearance";
|
||||||
export {
|
export {
|
||||||
colorModeSchema,
|
colorModeSchema,
|
||||||
colorThemeSchema,
|
defaultColorMode,
|
||||||
fallbackAppearance,
|
defaultPdfSettings,
|
||||||
fontPreferenceSchema,
|
|
||||||
hslChannelsSchema,
|
|
||||||
interfaceThemeSchema,
|
|
||||||
pdfTemplateSchema,
|
pdfTemplateSchema,
|
||||||
radiusPreferenceSchema,
|
|
||||||
sidebarStyleSchema,
|
|
||||||
} from "~/lib/appearance";
|
} from "~/lib/appearance";
|
||||||
|
|
||||||
export const interfaceThemes: {
|
|
||||||
value: InterfaceTheme;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
value: "beenvoice",
|
|
||||||
label: "beenvoice",
|
|
||||||
description:
|
|
||||||
"Playfair Display headings, Geist body text, and soft product chrome.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "frutiger",
|
|
||||||
label: "Frutiger Airport",
|
|
||||||
description:
|
|
||||||
"Rectangular blue-and-yellow wayfinding UI with Frutiger typography and docked navigation.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "frutiger-aero",
|
|
||||||
label: "Frutiger Aero",
|
|
||||||
description:
|
|
||||||
"Glossy sky-and-glass interface with Frutiger typography and softer surfaces.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "shadcn",
|
|
||||||
label: "shadcn/ui",
|
|
||||||
description: "A plain shadcn baseline for white-label starts.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "minimal",
|
|
||||||
label: "Minimal",
|
|
||||||
description: "Quiet surfaces, lower contrast, and restrained chrome.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "editorial",
|
|
||||||
label: "Editorial",
|
|
||||||
description: "A warmer presentation style for service-led brands.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const themePresets: Record<
|
|
||||||
InterfaceTheme,
|
|
||||||
{
|
|
||||||
interfaceTheme: InterfaceTheme;
|
|
||||||
bodyFontPreference: FontPreference;
|
|
||||||
headingFontPreference: FontPreference;
|
|
||||||
colorTheme: ColorTheme;
|
|
||||||
radiusPreference: RadiusPreference;
|
|
||||||
sidebarStyle: SidebarStyle;
|
|
||||||
pdfTemplate: PdfTemplate;
|
|
||||||
pdfAccentColor: string;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
beenvoice: {
|
|
||||||
interfaceTheme: "beenvoice",
|
|
||||||
bodyFontPreference: "brand",
|
|
||||||
headingFontPreference: "brand",
|
|
||||||
colorTheme: "slate",
|
|
||||||
radiusPreference: "xl",
|
|
||||||
sidebarStyle: "floating",
|
|
||||||
pdfTemplate: "classic",
|
|
||||||
pdfAccentColor: "#111827",
|
|
||||||
},
|
|
||||||
frutiger: {
|
|
||||||
interfaceTheme: "frutiger",
|
|
||||||
bodyFontPreference: "frutiger",
|
|
||||||
headingFontPreference: "frutiger",
|
|
||||||
colorTheme: "blue",
|
|
||||||
radiusPreference: "none",
|
|
||||||
sidebarStyle: "docked",
|
|
||||||
pdfTemplate: "minimal",
|
|
||||||
pdfAccentColor: "#003b5c",
|
|
||||||
},
|
|
||||||
"frutiger-aero": {
|
|
||||||
interfaceTheme: "frutiger-aero",
|
|
||||||
bodyFontPreference: "frutiger",
|
|
||||||
headingFontPreference: "frutiger",
|
|
||||||
colorTheme: "blue",
|
|
||||||
radiusPreference: "lg",
|
|
||||||
sidebarStyle: "floating",
|
|
||||||
pdfTemplate: "classic",
|
|
||||||
pdfAccentColor: "#0077be",
|
|
||||||
},
|
|
||||||
shadcn: {
|
|
||||||
interfaceTheme: "shadcn",
|
|
||||||
bodyFontPreference: "inter",
|
|
||||||
headingFontPreference: "inter",
|
|
||||||
colorTheme: "slate",
|
|
||||||
radiusPreference: "md",
|
|
||||||
sidebarStyle: "docked",
|
|
||||||
pdfTemplate: "classic",
|
|
||||||
pdfAccentColor: "#111827",
|
|
||||||
},
|
|
||||||
minimal: {
|
|
||||||
interfaceTheme: "minimal",
|
|
||||||
bodyFontPreference: "platform",
|
|
||||||
headingFontPreference: "platform",
|
|
||||||
colorTheme: "slate",
|
|
||||||
radiusPreference: "sm",
|
|
||||||
sidebarStyle: "docked",
|
|
||||||
pdfTemplate: "minimal",
|
|
||||||
pdfAccentColor: "#111827",
|
|
||||||
},
|
|
||||||
editorial: {
|
|
||||||
interfaceTheme: "editorial",
|
|
||||||
bodyFontPreference: "platform",
|
|
||||||
headingFontPreference: "serif",
|
|
||||||
colorTheme: "rose",
|
|
||||||
radiusPreference: "lg",
|
|
||||||
sidebarStyle: "floating",
|
|
||||||
pdfTemplate: "classic",
|
|
||||||
pdfAccentColor: "#be123c",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const bodyFontPreferences: {
|
|
||||||
value: FontPreference;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
value: "brand",
|
|
||||||
label: "Geist",
|
|
||||||
description: "Geist body text for the core beenvoice product feel.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "frutiger",
|
|
||||||
label: "Frutiger",
|
|
||||||
description: "Frutiger body text for signage-like operational screens.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "platform",
|
|
||||||
label: "Platform",
|
|
||||||
description: "Native system body text for the current OS.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "inter",
|
|
||||||
label: "Geist Legacy",
|
|
||||||
description: "Legacy sans option mapped to Geist for older installs.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "serif",
|
|
||||||
label: "Serif",
|
|
||||||
description: "Georgia-style body text for editorial deployments.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const headingFontPreferences: {
|
|
||||||
value: FontPreference;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
value: "brand",
|
|
||||||
label: "Playfair Display",
|
|
||||||
description: "Playfair Display headings for the beenvoice identity.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "frutiger",
|
|
||||||
label: "Frutiger",
|
|
||||||
description: "Frutiger headings for airport-inspired wayfinding.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "platform",
|
|
||||||
label: "Platform",
|
|
||||||
description: "Native system headings for a neutral app feel.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "inter",
|
|
||||||
label: "Geist Legacy",
|
|
||||||
description: "Legacy sans option mapped to Geist for older installs.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "serif",
|
|
||||||
label: "Editorial",
|
|
||||||
description: "Playfair headings with a stronger editorial tone.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const radiusPreferences: {
|
|
||||||
value: RadiusPreference;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}[] = [
|
|
||||||
{ value: "none", label: "Square", description: "No rounded corners." },
|
|
||||||
{ value: "sm", label: "Small", description: "Subtle 4px rounding." },
|
|
||||||
{ value: "md", label: "Medium", description: "Standard 8px rounding." },
|
|
||||||
{ value: "lg", label: "Large", description: "Soft 12px rounding." },
|
|
||||||
{
|
|
||||||
value: "xl",
|
|
||||||
label: "Extra Large",
|
|
||||||
description: "Expressive 16px rounding.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const sidebarStyles: {
|
|
||||||
value: SidebarStyle;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
value: "floating",
|
|
||||||
label: "Floating",
|
|
||||||
description: "Inset navigation with rounded edges and elevation.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "docked",
|
|
||||||
label: "Flush",
|
|
||||||
description: "Full-height navigation aligned to the viewport edge.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const colorThemes: {
|
|
||||||
value: ColorTheme;
|
|
||||||
label: string;
|
|
||||||
swatch: string;
|
|
||||||
}[] = [
|
|
||||||
{ value: "slate", label: "Slate", swatch: "hsl(240 5.9% 10%)" },
|
|
||||||
{ value: "blue", label: "Blue", swatch: "hsl(221.2 83.2% 53.3%)" },
|
|
||||||
{ value: "green", label: "Green", swatch: "hsl(142.1 76.2% 36.3%)" },
|
|
||||||
{ value: "rose", label: "Rose", swatch: "hsl(346.8 77.2% 49.8%)" },
|
|
||||||
{ value: "orange", label: "Orange", swatch: "hsl(24.6 95% 53.1%)" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const colorModes: {
|
export const colorModes: {
|
||||||
value: ColorMode;
|
value: ColorMode;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ value: "system", label: "System", description: "Follow device setting." },
|
{
|
||||||
{ value: "light", label: "Light", description: "Always use light mode." },
|
value: "system",
|
||||||
{ value: "dark", label: "Dark", description: "Always use dark mode." },
|
label: "System",
|
||||||
|
description: "Match your device light or dark setting.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "light",
|
||||||
|
label: "Light",
|
||||||
|
description: "Always use light mode.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dark",
|
||||||
|
label: "Dark",
|
||||||
|
description: "Always use dark mode.",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const defaultInterfaceTheme: InterfaceTheme =
|
|
||||||
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? fallbackAppearance.interfaceTheme;
|
|
||||||
|
|
||||||
export const defaultFontPreference: FontPreference =
|
|
||||||
env.NEXT_PUBLIC_DEFAULT_FONT ?? fallbackAppearance.fontPreference;
|
|
||||||
|
|
||||||
export const defaultBodyFontPreference: FontPreference =
|
|
||||||
env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference;
|
|
||||||
|
|
||||||
export const defaultHeadingFontPreference: FontPreference =
|
|
||||||
env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference;
|
|
||||||
|
|
||||||
export const defaultRadiusPreference: RadiusPreference =
|
|
||||||
env.NEXT_PUBLIC_DEFAULT_RADIUS ?? fallbackAppearance.radiusPreference;
|
|
||||||
|
|
||||||
export const defaultSidebarStyle: SidebarStyle =
|
|
||||||
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? fallbackAppearance.sidebarStyle;
|
|
||||||
|
|
||||||
export const brand = {
|
export const brand = {
|
||||||
name: env.NEXT_PUBLIC_BRAND_NAME ?? fallbackAppearance.brandName,
|
name: env.NEXT_PUBLIC_BRAND_NAME ?? "beenvoice",
|
||||||
tagline: env.NEXT_PUBLIC_BRAND_TAGLINE ?? fallbackAppearance.brandTagline,
|
tagline:
|
||||||
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? fallbackAppearance.brandLogoText,
|
env.NEXT_PUBLIC_BRAND_TAGLINE ??
|
||||||
icon: env.NEXT_PUBLIC_BRAND_ICON ?? fallbackAppearance.brandIcon,
|
"Simple and efficient invoicing for freelancers and small businesses",
|
||||||
|
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
|
||||||
|
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Split logo text for the `$ been` / `voice` styling used in Logo and OG images. */
|
||||||
|
export function splitLogoText(logoText: string) {
|
||||||
|
const voiceIndex = logoText.toLowerCase().indexOf("voice");
|
||||||
|
|
||||||
|
if (voiceIndex > 0) {
|
||||||
|
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
logoText.slice(0, Math.ceil(logoText.length / 2)),
|
||||||
|
logoText.slice(Math.ceil(logoText.length / 2)),
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const DATABASE_SETUP_HINT =
|
||||||
|
"Database not ready — run `bun db:migrate` (or `bun db:push` for local dev) after starting Postgres.";
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectErrorParts(error: unknown): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const seen = new Set<unknown>();
|
||||||
|
let current: unknown = error;
|
||||||
|
|
||||||
|
while (current && isRecord(current) && !seen.has(current)) {
|
||||||
|
seen.add(current);
|
||||||
|
|
||||||
|
if (typeof current.message === "string") {
|
||||||
|
parts.push(current.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof current.code === "string") {
|
||||||
|
parts.push(current.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.cause;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDatabaseSetupErrorMessage(error: unknown): string | null {
|
||||||
|
const haystack = collectErrorParts(error).join(" ").toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
haystack.includes("does not exist") ||
|
||||||
|
haystack.includes("42p01") ||
|
||||||
|
haystack.includes("econnrefused") ||
|
||||||
|
haystack.includes("connection refused") ||
|
||||||
|
haystack.includes("connect econnrefused")
|
||||||
|
) {
|
||||||
|
return DATABASE_SETUP_HINT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
|
|
||||||
interface InvoiceEmailTemplateProps {
|
interface InvoiceEmailTemplateProps {
|
||||||
invoice: {
|
invoice: {
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
@@ -44,7 +46,7 @@ export function generateInvoiceEmailTemplate({
|
|||||||
customMessage,
|
customMessage,
|
||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
baseUrl: _baseUrl = "https://beenvoice.app",
|
baseUrl = getAppUrl(),
|
||||||
}: InvoiceEmailTemplateProps): { html: string; text: string } {
|
}: InvoiceEmailTemplateProps): { html: string; text: string } {
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { formatEmailDate } from "src/lib/email-utils";
|
import { formatEmailDate } from "src/lib/email-utils";
|
||||||
|
import { SUPPORT_EMAIL } from "~/lib/app-email";
|
||||||
|
|
||||||
interface PasswordResetEmailProps {
|
interface PasswordResetEmailProps {
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
@@ -188,7 +189,7 @@ export function generatePasswordResetEmailTemplate({
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
beenvoice - Professional invoicing made simple<br>
|
beenvoice - Professional invoicing made simple<br>
|
||||||
<a href="mailto:support@beenvoice.com">support@beenvoice.com</a>
|
<a href="mailto:${SUPPORT_EMAIL}">${SUPPORT_EMAIL}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +214,7 @@ SECURITY INFORMATION:
|
|||||||
|
|
||||||
If you're having trouble with the link, copy and paste the entire URL into your browser's address bar.
|
If you're having trouble with the link, copy and paste the entire URL into your browser's address bar.
|
||||||
|
|
||||||
If you have any questions or need assistance, please contact our support team at support@beenvoice.com.
|
If you have any questions or need assistance, please contact our support team at ${SUPPORT_EMAIL}.
|
||||||
|
|
||||||
Best regards,
|
Best regards,
|
||||||
The beenvoice Team
|
The beenvoice Team
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { and, asc, eq, ne, sql } from "drizzle-orm";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { users } from "~/server/db/schema";
|
||||||
|
|
||||||
|
/** Seeded in drizzle/0014_seed_demo_account.sql for App Store review. */
|
||||||
|
export const DEMO_USER_EMAIL = "demo@example.com";
|
||||||
|
export const DEMO_USER_ID = "a0000000-0000-4000-8000-000000000001";
|
||||||
|
|
||||||
|
const FIRST_USER_ADMIN_LOCK_KEY = 0x62656e76;
|
||||||
|
|
||||||
|
type DbTx = Pick<typeof db, "execute" | "select" | "query" | "update">;
|
||||||
|
|
||||||
|
export function isDemoUser(user: {
|
||||||
|
email?: string | null;
|
||||||
|
id?: string | null;
|
||||||
|
}): boolean {
|
||||||
|
const email = user.email?.toLowerCase();
|
||||||
|
return email === DEMO_USER_EMAIL || user.id === DEMO_USER_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonDemoUserConditions() {
|
||||||
|
return and(ne(users.email, DEMO_USER_EMAIL), ne(users.id, DEMO_USER_ID));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireFirstUserAdminLock(tx: DbTx): Promise<void> {
|
||||||
|
await tx.execute(
|
||||||
|
sql`SELECT pg_advisory_xact_lock(${FIRST_USER_ADMIN_LOCK_KEY})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role for a user about to be inserted. Call inside a transaction before insert.
|
||||||
|
*/
|
||||||
|
export async function resolveNewUserRole(tx: DbTx): Promise<"admin" | "user"> {
|
||||||
|
await acquireFirstUserAdminLock(tx);
|
||||||
|
|
||||||
|
const [result] = await tx
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(users)
|
||||||
|
.where(nonDemoUserConditions());
|
||||||
|
|
||||||
|
return (result?.count ?? 0) === 0 ? "admin" : "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promote the first non-demo user to admin after Better Auth creates them (OAuth, etc.).
|
||||||
|
* Safe under concurrent sign-ups: only one non-demo admin is ever assigned.
|
||||||
|
*/
|
||||||
|
export async function promoteFirstRealUserIfNeeded(userId: string): Promise<void> {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await acquireFirstUserAdminLock(tx);
|
||||||
|
|
||||||
|
const user = await tx.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId),
|
||||||
|
columns: { id: true, email: true, role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || isDemoUser(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [adminResult] = await tx
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(users)
|
||||||
|
.where(and(nonDemoUserConditions(), eq(users.role, "admin")));
|
||||||
|
|
||||||
|
if ((adminResult?.count ?? 0) > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [firstRealUser] = await tx
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(nonDemoUserConditions())
|
||||||
|
.orderBy(asc(users.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (firstRealUser?.id === userId) {
|
||||||
|
await tx.update(users).set({ role: "admin" }).where(eq(users.id, userId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+6
-3
@@ -1,5 +1,8 @@
|
|||||||
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
|
import { LEGAL_EMAIL, PRIVACY_EMAIL } from "~/lib/app-email";
|
||||||
|
|
||||||
export const LEGAL_LAST_UPDATED = "June 18, 2026";
|
export const LEGAL_LAST_UPDATED = "June 18, 2026";
|
||||||
|
|
||||||
export const LEGAL_PRIVACY_EMAIL = "privacy@soconnor.dev";
|
export const LEGAL_PRIVACY_EMAIL = PRIVACY_EMAIL;
|
||||||
export const LEGAL_TERMS_EMAIL = "legal@soconnor.dev";
|
export const LEGAL_TERMS_EMAIL = LEGAL_EMAIL;
|
||||||
export const LEGAL_WEBSITE = "https://beenvoice.soconnor.dev";
|
export const LEGAL_WEBSITE = getAppUrl();
|
||||||
|
|||||||
@@ -32,6 +32,19 @@ export function isNavLinkActive(pathname: string, href: string): boolean {
|
|||||||
return pathname === href;
|
return pathname === href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ADMIN_ONLY_HREFS = new Set(["/dashboard/administration"]);
|
||||||
|
|
||||||
|
export function getNavigationForUser(isAdmin: boolean): NavSection[] {
|
||||||
|
return navigationConfig
|
||||||
|
.map((section) => ({
|
||||||
|
...section,
|
||||||
|
links: section.links.filter(
|
||||||
|
(link) => isAdmin || !ADMIN_ONLY_HREFS.has(link.href),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((section) => section.links.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
export const navigationConfig: NavSection[] = [
|
export const navigationConfig: NavSection[] = [
|
||||||
{
|
{
|
||||||
title: "Main",
|
title: "Main",
|
||||||
|
|||||||
+19
-5
@@ -15,6 +15,9 @@ const PLURALIZATION_RULES: Record<
|
|||||||
tax: { singular: "Tax", plural: "Taxes" },
|
tax: { singular: "Tax", plural: "Taxes" },
|
||||||
category: { singular: "Category", plural: "Categories" },
|
category: { singular: "Category", plural: "Categories" },
|
||||||
company: { singular: "Company", plural: "Companies" },
|
company: { singular: "Company", plural: "Companies" },
|
||||||
|
entity: { singular: "Entity", plural: "Entities" },
|
||||||
|
expense: { singular: "Expense", plural: "Expenses" },
|
||||||
|
report: { singular: "Report", plural: "Reports" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,20 +108,31 @@ export function capitalize(word: string): string {
|
|||||||
* Get a properly formatted label for a route segment
|
* Get a properly formatted label for a route segment
|
||||||
*/
|
*/
|
||||||
export function getRouteLabel(segment: string, isPlural = true): string {
|
export function getRouteLabel(segment: string, isPlural = true): string {
|
||||||
// First, check if it's already in our rules
|
const lower = segment.toLowerCase();
|
||||||
const rule = PLURALIZATION_RULES[segment.toLowerCase()];
|
|
||||||
|
// Route segments are often already plural (e.g. "entities", "invoices")
|
||||||
|
const ruleByPlural = Object.values(PLURALIZATION_RULES).find(
|
||||||
|
(r) => r.plural.toLowerCase() === lower,
|
||||||
|
);
|
||||||
|
if (ruleByPlural) {
|
||||||
|
return isPlural ? ruleByPlural.plural : ruleByPlural.singular;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = PLURALIZATION_RULES[lower];
|
||||||
if (rule) {
|
if (rule) {
|
||||||
return isPlural ? rule.plural : rule.singular;
|
return isPlural ? rule.plural : rule.singular;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not, try to find it by plural form
|
|
||||||
const singularForm = singularize(segment);
|
const singularForm = singularize(segment);
|
||||||
const singularRule = PLURALIZATION_RULES[singularForm.toLowerCase()];
|
const singularRule = PLURALIZATION_RULES[singularForm.toLowerCase()];
|
||||||
if (singularRule) {
|
if (singularRule) {
|
||||||
return isPlural ? singularRule.plural : singularRule.singular;
|
return isPlural ? singularRule.plural : singularRule.singular;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, just capitalize and optionally pluralize
|
|
||||||
const capitalized = capitalize(segment);
|
const capitalized = capitalize(segment);
|
||||||
return isPlural ? pluralize(capitalized) : capitalized;
|
if (isPlural && /s$/i.test(segment)) {
|
||||||
|
return capitalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isPlural ? pluralize(capitalized) : capitalize(singularForm);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-2
@@ -1,4 +1,5 @@
|
|||||||
export const DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
/** Stored on entries clocked in before empty descriptions were allowed. */
|
||||||
|
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
||||||
|
|
||||||
export function resolveEffectiveHourlyRate(
|
export function resolveEffectiveHourlyRate(
|
||||||
enteredRate: number,
|
enteredRate: number,
|
||||||
@@ -21,7 +22,21 @@ export function resolveClockDescription(
|
|||||||
const trimmed = title.trim();
|
const trimmed = title.trim();
|
||||||
if (trimmed) return trimmed;
|
if (trimmed) return trimmed;
|
||||||
if (existingDescription?.trim()) return existingDescription.trim();
|
if (existingDescription?.trim()) return existingDescription.trim();
|
||||||
return DEFAULT_CLOCK_DESCRIPTION;
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRunningTimerLabel(description?: string | null): string {
|
||||||
|
const trimmed = description?.trim() ?? "";
|
||||||
|
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) return "Clocked in";
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBillingDescription(description?: string | null): string {
|
||||||
|
const trimmed = description?.trim() ?? "";
|
||||||
|
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) {
|
||||||
|
return LEGACY_DEFAULT_CLOCK_DESCRIPTION;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClockOutOutcome =
|
export type ClockOutOutcome =
|
||||||
|
|||||||
@@ -1,59 +1,68 @@
|
|||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
import { invoices, clients } from "~/server/db/schema";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import { and, desc, eq, isNotNull, lte } from "drizzle-orm";
|
import { clients, invoices } from "~/server/db/schema";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
|
||||||
export const dashboardRouter = createTRPCRouter({
|
type LiteInvoice = {
|
||||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
id: string;
|
||||||
const userId = ctx.session.user.id;
|
totalAmount: number;
|
||||||
const now = new Date();
|
status: string;
|
||||||
|
dueDate: Date;
|
||||||
|
issueDate: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildRevenueMonthKeys(now: Date, count: number) {
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (let i = count - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
keys.push(
|
||||||
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
||||||
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
|
||||||
// 1. Fetch all invoices for the user to calculate stats
|
|
||||||
// Note: For very large datasets, we should use separate count/sum queries,
|
|
||||||
// but for typical usage, fetching fields is fine and allows flexible JS calculation
|
|
||||||
// where SQL complexity might be high (e.g. dynamic status).
|
|
||||||
// However, let's try to be efficient with SQL where possible.
|
|
||||||
|
|
||||||
const userInvoices = await ctx.db.query.invoices.findMany({
|
|
||||||
where: eq(invoices.createdById, userId),
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
totalAmount: true,
|
|
||||||
status: true,
|
|
||||||
dueDate: true,
|
|
||||||
issueDate: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const userClientsCount = await ctx.db.$count(
|
|
||||||
clients,
|
|
||||||
eq(clients.createdById, userId),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper to check status
|
|
||||||
const getStatus = (inv: (typeof userInvoices)[0]) => {
|
|
||||||
if (inv.status === "paid") return "paid";
|
|
||||||
if (inv.status === "draft") return "draft";
|
|
||||||
if (new Date(inv.dueDate) < now && inv.status !== "paid")
|
|
||||||
return "overdue";
|
|
||||||
return "sent";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate Stats
|
|
||||||
let totalRevenue = 0;
|
let totalRevenue = 0;
|
||||||
let pendingAmount = 0;
|
let pendingAmount = 0;
|
||||||
let overdueCount = 0;
|
let overdueCount = 0;
|
||||||
|
|
||||||
let currentMonthRevenue = 0;
|
let currentMonthRevenue = 0;
|
||||||
let lastMonthRevenue = 0;
|
let lastMonthRevenue = 0;
|
||||||
|
|
||||||
|
const revenueByMonth = Object.fromEntries(
|
||||||
|
buildRevenueMonthKeys(now, 6).map((key) => [key, 0]),
|
||||||
|
) as Record<string, number>;
|
||||||
|
|
||||||
|
const statusTotals: Record<
|
||||||
|
string,
|
||||||
|
{ status: string; count: number; value: number }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
const monthlyTotals: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
month: string;
|
||||||
|
totalInvoices: number;
|
||||||
|
paidInvoices: number;
|
||||||
|
pendingInvoices: number;
|
||||||
|
overdueInvoices: number;
|
||||||
|
draftInvoices: number;
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
|
||||||
for (const inv of userInvoices) {
|
for (const inv of userInvoices) {
|
||||||
const status = getStatus(inv);
|
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
);
|
||||||
const amount = inv.totalAmount;
|
const amount = inv.totalAmount;
|
||||||
const issueDate = new Date(inv.issueDate);
|
const issueDate = new Date(inv.issueDate);
|
||||||
|
|
||||||
if (status === "paid") {
|
if (effectiveStatus === "paid") {
|
||||||
totalRevenue += amount;
|
totalRevenue += amount;
|
||||||
|
|
||||||
if (issueDate >= currentMonthStart) {
|
if (issueDate >= currentMonthStart) {
|
||||||
@@ -64,30 +73,52 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
) {
|
) {
|
||||||
lastMonthRevenue += amount;
|
lastMonthRevenue += amount;
|
||||||
}
|
}
|
||||||
} else if (status === "sent" || status === "overdue") {
|
|
||||||
|
const revenueKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const monthRevenue = revenueByMonth[revenueKey];
|
||||||
|
if (monthRevenue !== undefined) {
|
||||||
|
revenueByMonth[revenueKey] = monthRevenue + amount;
|
||||||
|
}
|
||||||
|
} else if (effectiveStatus === "sent" || effectiveStatus === "overdue") {
|
||||||
pendingAmount += amount;
|
pendingAmount += amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "overdue") {
|
if (effectiveStatus === "overdue") {
|
||||||
overdueCount++;
|
overdueCount++;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Revenue Trend (Last 6 months)
|
statusTotals[effectiveStatus] ??= {
|
||||||
const revenueByMonth: Record<string, number> = {};
|
status: effectiveStatus,
|
||||||
for (let i = 0; i < 6; i++) {
|
count: 0,
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
value: 0,
|
||||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
};
|
||||||
revenueByMonth[key] = 0;
|
statusTotals[effectiveStatus].count += 1;
|
||||||
}
|
statusTotals[effectiveStatus].value += amount;
|
||||||
|
|
||||||
for (const inv of userInvoices) {
|
const monthKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
|
||||||
if (getStatus(inv) === "paid") {
|
monthlyTotals[monthKey] ??= {
|
||||||
const d = new Date(inv.issueDate);
|
month: monthKey,
|
||||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
totalInvoices: 0,
|
||||||
if (revenueByMonth[key] !== undefined) {
|
paidInvoices: 0,
|
||||||
revenueByMonth[key] += inv.totalAmount;
|
pendingInvoices: 0,
|
||||||
}
|
overdueInvoices: 0,
|
||||||
|
draftInvoices: 0,
|
||||||
|
};
|
||||||
|
monthlyTotals[monthKey].totalInvoices += 1;
|
||||||
|
|
||||||
|
switch (effectiveStatus) {
|
||||||
|
case "paid":
|
||||||
|
monthlyTotals[monthKey].paidInvoices += 1;
|
||||||
|
break;
|
||||||
|
case "sent":
|
||||||
|
monthlyTotals[monthKey].pendingInvoices += 1;
|
||||||
|
break;
|
||||||
|
case "overdue":
|
||||||
|
monthlyTotals[monthKey].overdueInvoices += 1;
|
||||||
|
break;
|
||||||
|
case "draft":
|
||||||
|
monthlyTotals[monthKey].draftInvoices += 1;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,8 +133,59 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => a.month.localeCompare(b.month));
|
.sort((a, b) => a.month.localeCompare(b.month));
|
||||||
|
|
||||||
// Recent Activity
|
const statusChartData = Object.values(statusTotals).map((item) => ({
|
||||||
const recentInvoices = await ctx.db.query.invoices.findMany({
|
...item,
|
||||||
|
name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const monthlyMetricsChartData = Object.values(monthlyTotals)
|
||||||
|
.sort((a, b) => a.month.localeCompare(b.month))
|
||||||
|
.slice(-6)
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRevenue,
|
||||||
|
pendingAmount,
|
||||||
|
overdueCount,
|
||||||
|
revenueChange:
|
||||||
|
lastMonthRevenue > 0
|
||||||
|
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
|
||||||
|
: 0,
|
||||||
|
revenueChartData,
|
||||||
|
statusChartData,
|
||||||
|
monthlyMetricsChartData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardRouter = createTRPCRouter({
|
||||||
|
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const userId = ctx.session.user.id;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const [
|
||||||
|
userInvoices,
|
||||||
|
userClientsCount,
|
||||||
|
recentInvoices,
|
||||||
|
currentDraft,
|
||||||
|
] = await Promise.all([
|
||||||
|
ctx.db.query.invoices.findMany({
|
||||||
|
where: eq(invoices.createdById, userId),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
totalAmount: true,
|
||||||
|
status: true,
|
||||||
|
dueDate: true,
|
||||||
|
issueDate: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.db.$count(clients, eq(clients.createdById, userId)),
|
||||||
|
ctx.db.query.invoices.findMany({
|
||||||
where: eq(invoices.createdById, userId),
|
where: eq(invoices.createdById, userId),
|
||||||
orderBy: [
|
orderBy: [
|
||||||
desc(invoices.issueDate),
|
desc(invoices.issueDate),
|
||||||
@@ -116,40 +198,47 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
columns: { name: true },
|
columns: { name: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
ctx.db.query.invoices.findFirst({
|
||||||
const sendReminderDue = await ctx.db.query.invoices.findMany({
|
|
||||||
where: and(
|
where: and(
|
||||||
eq(invoices.createdById, userId),
|
eq(invoices.createdById, userId),
|
||||||
eq(invoices.status, "draft"),
|
eq(invoices.status, "draft"),
|
||||||
isNotNull(invoices.sendReminderAt),
|
|
||||||
lte(invoices.sendReminderAt, now),
|
|
||||||
),
|
),
|
||||||
|
orderBy: [
|
||||||
|
desc(invoices.issueDate),
|
||||||
|
desc(invoices.dueDate),
|
||||||
|
desc(invoices.invoiceNumber),
|
||||||
|
],
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
invoiceNumber: true,
|
invoiceNumber: true,
|
||||||
invoicePrefix: true,
|
totalAmount: true,
|
||||||
sendReminderAt: true,
|
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
client: { columns: { name: true } },
|
client: { columns: { name: true } },
|
||||||
|
items: { columns: { hours: true } },
|
||||||
},
|
},
|
||||||
orderBy: [desc(invoices.sendReminderAt)],
|
}),
|
||||||
limit: 10,
|
]);
|
||||||
});
|
|
||||||
|
const metrics = aggregateDashboardMetrics(userInvoices, now);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalRevenue,
|
...metrics,
|
||||||
pendingAmount,
|
|
||||||
overdueCount,
|
|
||||||
totalClients: userClientsCount,
|
totalClients: userClientsCount,
|
||||||
revenueChange:
|
|
||||||
lastMonthRevenue > 0
|
|
||||||
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
|
|
||||||
: 0,
|
|
||||||
revenueChartData,
|
|
||||||
recentInvoices,
|
recentInvoices,
|
||||||
sendReminderDue,
|
currentDraft: currentDraft
|
||||||
|
? {
|
||||||
|
id: currentDraft.id,
|
||||||
|
invoiceNumber: currentDraft.invoiceNumber,
|
||||||
|
totalAmount: currentDraft.totalAmount,
|
||||||
|
client: currentDraft.client,
|
||||||
|
totalHours: currentDraft.items.reduce(
|
||||||
|
(sum, item) => sum + item.hours,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
|||||||
import { invoices, platformSettings } from "~/server/db/schema";
|
import { invoices, platformSettings } from "~/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
|
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||||
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||||
|
|
||||||
@@ -153,7 +155,7 @@ export const emailRouter = createTRPCRouter({
|
|||||||
customMessage,
|
customMessage,
|
||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
baseUrl: getAppUrl(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Determine Resend instance and email configuration to use
|
// Determine Resend instance and email configuration to use
|
||||||
@@ -177,7 +179,7 @@ export const emailRouter = createTRPCRouter({
|
|||||||
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
|
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
|
||||||
} else if (env.RESEND_API_KEY) {
|
} else if (env.RESEND_API_KEY) {
|
||||||
resendInstance = new Resend(env.RESEND_API_KEY);
|
resendInstance = new Resend(env.RESEND_API_KEY);
|
||||||
fromEmail = invoice.business?.email ?? "noreply@example.com";
|
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Email delivery is not configured. Add a Resend API key globally or on this business.",
|
"Email delivery is not configured. Add a Resend API key globally or on this business.",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
|
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||||
import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
|
import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
|
||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
|
|
||||||
@@ -439,20 +440,10 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items && existingInvoice.status !== "draft") {
|
if (existingInvoice.status !== "draft") {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Line items can only be edited on draft invoices",
|
message: "Only draft invoices can be edited",
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
cleanInvoiceData.sendReminderAt !== undefined &&
|
|
||||||
existingInvoice.status !== "draft"
|
|
||||||
) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "Send reminders can only be set on draft invoices",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,7 +835,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
|
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
|
||||||
} else if (env.RESEND_API_KEY) {
|
} else if (env.RESEND_API_KEY) {
|
||||||
resendInstance = new Resend(env.RESEND_API_KEY);
|
resendInstance = new Resend(env.RESEND_API_KEY);
|
||||||
fromEmail = invoice.business?.email ?? "noreply@example.com";
|
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
|
||||||
} else {
|
} else {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
|
|||||||
+517
-148
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import {
|
import {
|
||||||
@@ -14,29 +14,20 @@ import {
|
|||||||
businesses,
|
businesses,
|
||||||
invoices,
|
invoices,
|
||||||
invoiceItems,
|
invoiceItems,
|
||||||
|
invoicePayments,
|
||||||
|
invoiceTemplates,
|
||||||
|
expenses,
|
||||||
|
recurringInvoices,
|
||||||
|
recurringInvoiceItems,
|
||||||
|
timeEntries,
|
||||||
platformSettings,
|
platformSettings,
|
||||||
} from "~/server/db/schema";
|
} from "~/server/db/schema";
|
||||||
import {
|
import {
|
||||||
colorModeSchema,
|
colorModeSchema,
|
||||||
colorThemeSchema,
|
defaultColorMode,
|
||||||
defaultBodyFontPreference,
|
defaultPdfSettings,
|
||||||
defaultHeadingFontPreference,
|
|
||||||
defaultInterfaceTheme,
|
|
||||||
defaultRadiusPreference,
|
|
||||||
defaultSidebarStyle,
|
|
||||||
fallbackAppearance,
|
|
||||||
fontPreferenceSchema,
|
|
||||||
hslChannelsSchema,
|
|
||||||
interfaceThemeSchema,
|
|
||||||
pdfTemplateSchema,
|
pdfTemplateSchema,
|
||||||
radiusPreferenceSchema,
|
|
||||||
sidebarStyleSchema,
|
|
||||||
type ColorMode,
|
type ColorMode,
|
||||||
type ColorTheme,
|
|
||||||
type FontPreference,
|
|
||||||
type InterfaceTheme,
|
|
||||||
type RadiusPreference,
|
|
||||||
type SidebarStyle,
|
|
||||||
} from "~/lib/branding";
|
} from "~/lib/branding";
|
||||||
import type { db as database } from "~/server/db";
|
import type { db as database } from "~/server/db";
|
||||||
|
|
||||||
@@ -54,6 +45,24 @@ async function requireAdmin(ctx: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveBusinessId(
|
||||||
|
refs: { businessName?: string; businessNickname?: string },
|
||||||
|
businessIdMap: Map<string, string>,
|
||||||
|
): string | null {
|
||||||
|
if (refs.businessNickname) {
|
||||||
|
const byNickname = businessIdMap.get(refs.businessNickname);
|
||||||
|
if (byNickname) return byNickname;
|
||||||
|
}
|
||||||
|
if (refs.businessName) {
|
||||||
|
return businessIdMap.get(refs.businessName) ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function backupImportError(message: string): never {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message });
|
||||||
|
}
|
||||||
|
|
||||||
// Validation schemas for backup data
|
// Validation schemas for backup data
|
||||||
const ClientBackupSchema = z.object({
|
const ClientBackupSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -65,6 +74,8 @@ const ClientBackupSchema = z.object({
|
|||||||
state: z.string().optional(),
|
state: z.string().optional(),
|
||||||
postalCode: z.string().optional(),
|
postalCode: z.string().optional(),
|
||||||
country: z.string().optional(),
|
country: z.string().optional(),
|
||||||
|
defaultHourlyRate: z.number().optional(),
|
||||||
|
currency: z.string().default("USD"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const BusinessBackupSchema = z.object({
|
const BusinessBackupSchema = z.object({
|
||||||
@@ -82,10 +93,13 @@ const BusinessBackupSchema = z.object({
|
|||||||
taxId: z.string().optional(),
|
taxId: z.string().optional(),
|
||||||
logoUrl: z.string().optional(),
|
logoUrl: z.string().optional(),
|
||||||
isDefault: z.boolean().default(false),
|
isDefault: z.boolean().default(false),
|
||||||
|
resendApiKey: z.string().optional(),
|
||||||
|
resendDomain: z.string().optional(),
|
||||||
|
emailFromName: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const InvoiceItemBackupSchema = z.object({
|
const InvoiceItemBackupSchema = z.object({
|
||||||
date: z.string().transform((str) => new Date(str)),
|
date: z.coerce.date(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
hours: z.number(),
|
hours: z.number(),
|
||||||
rate: z.number(),
|
rate: z.number(),
|
||||||
@@ -95,29 +109,107 @@ const InvoiceItemBackupSchema = z.object({
|
|||||||
|
|
||||||
const InvoiceBackupSchema = z.object({
|
const InvoiceBackupSchema = z.object({
|
||||||
invoiceNumber: z.string(),
|
invoiceNumber: z.string(),
|
||||||
|
invoicePrefix: z.string().default("#"),
|
||||||
businessName: z.string().optional(),
|
businessName: z.string().optional(),
|
||||||
businessNickname: z.string().optional(),
|
businessNickname: z.string().optional(),
|
||||||
clientName: z.string(),
|
clientName: z.string(),
|
||||||
issueDate: z.string().transform((str) => new Date(str)),
|
issueDate: z.coerce.date(),
|
||||||
dueDate: z.string().transform((str) => new Date(str)),
|
dueDate: z.coerce.date(),
|
||||||
status: z.string().default("draft"),
|
status: z.string().default("draft"),
|
||||||
totalAmount: z.number().default(0),
|
totalAmount: z.number().default(0),
|
||||||
taxRate: z.number().default(0),
|
taxRate: z.number().default(0),
|
||||||
|
currency: z.string().default("USD"),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
emailMessage: z.string().optional(),
|
emailMessage: z.string().optional(),
|
||||||
items: z.array(InvoiceItemBackupSchema),
|
items: z.array(InvoiceItemBackupSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const InvoicePaymentBackupSchema = z.object({
|
||||||
|
invoiceNumber: z.string(),
|
||||||
|
amount: z.number(),
|
||||||
|
currency: z.string().default("USD"),
|
||||||
|
date: z.coerce.date(),
|
||||||
|
method: z.string().default("other"),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const InvoiceTemplateBackupSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
type: z.string().default("notes"),
|
||||||
|
content: z.string(),
|
||||||
|
isDefault: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RecurringInvoiceItemBackupSchema = z.object({
|
||||||
|
description: z.string(),
|
||||||
|
hours: z.number(),
|
||||||
|
rate: z.number(),
|
||||||
|
position: z.number().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RecurringInvoiceBackupSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
clientName: z.string(),
|
||||||
|
businessName: z.string().optional(),
|
||||||
|
businessNickname: z.string().optional(),
|
||||||
|
schedule: z.string().default("monthly"),
|
||||||
|
status: z.string().default("active"),
|
||||||
|
invoicePrefix: z.string().default("#"),
|
||||||
|
taxRate: z.number().default(0),
|
||||||
|
currency: z.string().default("USD"),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
emailMessage: z.string().optional(),
|
||||||
|
nextDueAt: z.coerce.date(),
|
||||||
|
lastGeneratedAt: z.coerce.date().optional(),
|
||||||
|
items: z.array(RecurringInvoiceItemBackupSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ExpenseBackupSchema = z.object({
|
||||||
|
clientName: z.string().optional(),
|
||||||
|
businessName: z.string().optional(),
|
||||||
|
businessNickname: z.string().optional(),
|
||||||
|
invoiceNumber: z.string().optional(),
|
||||||
|
date: z.coerce.date(),
|
||||||
|
description: z.string(),
|
||||||
|
amount: z.number(),
|
||||||
|
currency: z.string().default("USD"),
|
||||||
|
category: z.string().optional(),
|
||||||
|
billable: z.boolean().default(false),
|
||||||
|
reimbursable: z.boolean().default(false),
|
||||||
|
taxDeductible: z.boolean().default(false),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TimeEntryBackupSchema = z.object({
|
||||||
|
description: z.string().default(""),
|
||||||
|
clientName: z.string().optional(),
|
||||||
|
invoiceNumber: z.string().optional(),
|
||||||
|
startedAt: z.coerce.date(),
|
||||||
|
endedAt: z.coerce.date().optional(),
|
||||||
|
hours: z.number().optional(),
|
||||||
|
rate: z.number().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const BackupDataSchema = z.object({
|
const BackupDataSchema = z.object({
|
||||||
exportDate: z.string(),
|
exportDate: z.string(),
|
||||||
version: z.string().default("1.0"),
|
version: z.string().default("2.0"),
|
||||||
user: z.object({
|
user: z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
|
prefersReducedMotion: z.boolean().optional(),
|
||||||
|
animationSpeedMultiplier: z.number().optional(),
|
||||||
|
theme: z.string().optional(),
|
||||||
|
onboardingCompletedAt: z.coerce.date().nullable().optional(),
|
||||||
}),
|
}),
|
||||||
clients: z.array(ClientBackupSchema),
|
clients: z.array(ClientBackupSchema),
|
||||||
businesses: z.array(BusinessBackupSchema),
|
businesses: z.array(BusinessBackupSchema),
|
||||||
invoices: z.array(InvoiceBackupSchema),
|
invoices: z.array(InvoiceBackupSchema),
|
||||||
|
invoicePayments: z.array(InvoicePaymentBackupSchema).default([]),
|
||||||
|
invoiceTemplates: z.array(InvoiceTemplateBackupSchema).default([]),
|
||||||
|
recurringInvoices: z.array(RecurringInvoiceBackupSchema).default([]),
|
||||||
|
expenses: z.array(ExpenseBackupSchema).default([]),
|
||||||
|
timeEntries: z.array(TimeEntryBackupSchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const settingsRouter = createTRPCRouter({
|
export const settingsRouter = createTRPCRouter({
|
||||||
@@ -162,12 +254,45 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
email: true,
|
email: true,
|
||||||
image: true,
|
image: true,
|
||||||
role: true,
|
role: true,
|
||||||
|
onboardingCompletedAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getOnboardingStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const user = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, ctx.session.user.id),
|
||||||
|
columns: {
|
||||||
|
onboardingCompletedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [businessCount, clientCount] = await Promise.all([
|
||||||
|
ctx.db.$count(
|
||||||
|
businesses,
|
||||||
|
eq(businesses.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
ctx.db.$count(clients, eq(clients.createdById, ctx.session.user.id)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
completed: user?.onboardingCompletedAt != null,
|
||||||
|
businessCount,
|
||||||
|
clientCount,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
completeOnboarding: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
|
await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set({ onboardingCompletedAt: new Date() })
|
||||||
|
.where(eq(users.id, ctx.session.user.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
// Get animation preferences
|
// Get animation preferences
|
||||||
getAnimationPreferences: publicProcedure.query(async ({ ctx }) => {
|
getAnimationPreferences: publicProcedure.query(async ({ ctx }) => {
|
||||||
// Return defaults if not authenticated
|
// Return defaults if not authenticated
|
||||||
@@ -220,64 +345,54 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Get theme preferences
|
getColorMode: protectedProcedure.query(async ({ ctx }) => {
|
||||||
getTheme: publicProcedure.query(async ({ ctx }) => {
|
const user = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, ctx.session.user.id),
|
||||||
|
columns: { theme: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
colorMode: (user?.theme as ColorMode) ?? defaultColorMode,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateColorMode: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
colorMode: colorModeSchema,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set({ theme: input.colorMode })
|
||||||
|
.where(eq(users.id, ctx.session.user.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
getPdfSettings: publicProcedure.query(async ({ ctx }) => {
|
||||||
const settings = await ctx.db.query.platformSettings.findFirst({
|
const settings = await ctx.db.query.platformSettings.findFirst({
|
||||||
where: eq(platformSettings.id, "global"),
|
where: eq(platformSettings.id, "global"),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
colorTheme:
|
|
||||||
(settings?.colorTheme as ColorTheme) ?? fallbackAppearance.colorTheme,
|
|
||||||
customColor: settings?.customColor ?? undefined,
|
|
||||||
theme: (settings?.theme as ColorMode) ?? fallbackAppearance.colorMode,
|
|
||||||
interfaceTheme:
|
|
||||||
(settings?.interfaceTheme as InterfaceTheme) ?? defaultInterfaceTheme,
|
|
||||||
bodyFontPreference:
|
|
||||||
(settings?.bodyFontPreference as FontPreference) ??
|
|
||||||
defaultBodyFontPreference,
|
|
||||||
headingFontPreference:
|
|
||||||
(settings?.headingFontPreference as FontPreference) ??
|
|
||||||
defaultHeadingFontPreference,
|
|
||||||
radiusPreference:
|
|
||||||
(settings?.radiusPreference as RadiusPreference) ??
|
|
||||||
defaultRadiusPreference,
|
|
||||||
sidebarStyle:
|
|
||||||
(settings?.sidebarStyle as SidebarStyle) ?? defaultSidebarStyle,
|
|
||||||
brandName: settings?.brandName ?? fallbackAppearance.brandName,
|
|
||||||
brandTagline: settings?.brandTagline ?? fallbackAppearance.brandTagline,
|
|
||||||
brandLogoText:
|
|
||||||
settings?.brandLogoText ?? fallbackAppearance.brandLogoText,
|
|
||||||
brandIcon: settings?.brandIcon ?? fallbackAppearance.brandIcon,
|
|
||||||
pdfTemplate:
|
pdfTemplate:
|
||||||
(settings?.pdfTemplate as "classic" | "minimal") ??
|
(settings?.pdfTemplate as "classic" | "minimal") ??
|
||||||
fallbackAppearance.pdfTemplate,
|
defaultPdfSettings.pdfTemplate,
|
||||||
pdfAccentColor:
|
pdfAccentColor:
|
||||||
settings?.pdfAccentColor ?? fallbackAppearance.pdfAccentColor,
|
settings?.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
|
||||||
pdfFooterText:
|
pdfFooterText:
|
||||||
settings?.pdfFooterText ?? fallbackAppearance.pdfFooterText,
|
settings?.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
|
||||||
pdfShowLogo: settings?.pdfShowLogo ?? fallbackAppearance.pdfShowLogo,
|
pdfShowLogo: settings?.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
|
||||||
pdfShowPageNumbers:
|
pdfShowPageNumbers:
|
||||||
settings?.pdfShowPageNumbers ?? fallbackAppearance.pdfShowPageNumbers,
|
settings?.pdfShowPageNumbers ?? defaultPdfSettings.pdfShowPageNumbers,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Update theme preferences
|
updatePdfSettings: protectedProcedure
|
||||||
updateTheme: protectedProcedure
|
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
colorTheme: colorThemeSchema.optional(),
|
|
||||||
customColor: hslChannelsSchema.optional(),
|
|
||||||
theme: colorModeSchema.optional(),
|
|
||||||
interfaceTheme: interfaceThemeSchema.optional(),
|
|
||||||
bodyFontPreference: fontPreferenceSchema.optional(),
|
|
||||||
headingFontPreference: fontPreferenceSchema.optional(),
|
|
||||||
radiusPreference: radiusPreferenceSchema.optional(),
|
|
||||||
sidebarStyle: sidebarStyleSchema.optional(),
|
|
||||||
brandName: z.string().min(1).max(100).optional(),
|
|
||||||
brandTagline: z.string().min(1).max(255).optional(),
|
|
||||||
brandLogoText: z.string().min(1).max(100).optional(),
|
|
||||||
brandIcon: z.string().min(1).max(20).optional(),
|
|
||||||
pdfTemplate: pdfTemplateSchema.optional(),
|
pdfTemplate: pdfTemplateSchema.optional(),
|
||||||
pdfAccentColor: z.string().min(4).max(50).optional(),
|
pdfAccentColor: z.string().min(4).max(50).optional(),
|
||||||
pdfFooterText: z.string().min(1).max(120).optional(),
|
pdfFooterText: z.string().min(1).max(120).optional(),
|
||||||
@@ -291,57 +406,18 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
.insert(platformSettings)
|
.insert(platformSettings)
|
||||||
.values({
|
.values({
|
||||||
id: "global",
|
id: "global",
|
||||||
brandName: input.brandName ?? fallbackAppearance.brandName,
|
pdfTemplate: input.pdfTemplate ?? defaultPdfSettings.pdfTemplate,
|
||||||
brandTagline: input.brandTagline ?? fallbackAppearance.brandTagline,
|
|
||||||
brandLogoText:
|
|
||||||
input.brandLogoText ?? fallbackAppearance.brandLogoText,
|
|
||||||
brandIcon: input.brandIcon ?? fallbackAppearance.brandIcon,
|
|
||||||
colorTheme: input.colorTheme ?? fallbackAppearance.colorTheme,
|
|
||||||
customColor: input.customColor,
|
|
||||||
theme: input.theme ?? fallbackAppearance.colorMode,
|
|
||||||
interfaceTheme: input.interfaceTheme ?? defaultInterfaceTheme,
|
|
||||||
bodyFontPreference:
|
|
||||||
input.bodyFontPreference ?? defaultBodyFontPreference,
|
|
||||||
headingFontPreference:
|
|
||||||
input.headingFontPreference ?? defaultHeadingFontPreference,
|
|
||||||
radiusPreference: input.radiusPreference ?? defaultRadiusPreference,
|
|
||||||
sidebarStyle: input.sidebarStyle ?? defaultSidebarStyle,
|
|
||||||
pdfTemplate: input.pdfTemplate ?? fallbackAppearance.pdfTemplate,
|
|
||||||
pdfAccentColor:
|
pdfAccentColor:
|
||||||
input.pdfAccentColor ?? fallbackAppearance.pdfAccentColor,
|
input.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
|
||||||
pdfFooterText:
|
pdfFooterText:
|
||||||
input.pdfFooterText ?? fallbackAppearance.pdfFooterText,
|
input.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
|
||||||
pdfShowLogo: input.pdfShowLogo ?? fallbackAppearance.pdfShowLogo,
|
pdfShowLogo: input.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
|
||||||
pdfShowPageNumbers:
|
pdfShowPageNumbers:
|
||||||
input.pdfShowPageNumbers ?? fallbackAppearance.pdfShowPageNumbers,
|
input.pdfShowPageNumbers ?? defaultPdfSettings.pdfShowPageNumbers,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: platformSettings.id,
|
target: platformSettings.id,
|
||||||
set: {
|
set: {
|
||||||
...(input.brandName && { brandName: input.brandName }),
|
|
||||||
...(input.brandTagline && { brandTagline: input.brandTagline }),
|
|
||||||
...(input.brandLogoText && {
|
|
||||||
brandLogoText: input.brandLogoText,
|
|
||||||
}),
|
|
||||||
...(input.brandIcon && { brandIcon: input.brandIcon }),
|
|
||||||
...(input.colorTheme && { colorTheme: input.colorTheme }),
|
|
||||||
...(input.customColor !== undefined && {
|
|
||||||
customColor: input.customColor,
|
|
||||||
}),
|
|
||||||
...(input.theme && { theme: input.theme }),
|
|
||||||
...(input.interfaceTheme && {
|
|
||||||
interfaceTheme: input.interfaceTheme,
|
|
||||||
}),
|
|
||||||
...(input.bodyFontPreference && {
|
|
||||||
bodyFontPreference: input.bodyFontPreference,
|
|
||||||
}),
|
|
||||||
...(input.headingFontPreference && {
|
|
||||||
headingFontPreference: input.headingFontPreference,
|
|
||||||
}),
|
|
||||||
...(input.radiusPreference && {
|
|
||||||
radiusPreference: input.radiusPreference,
|
|
||||||
}),
|
|
||||||
...(input.sidebarStyle && { sidebarStyle: input.sidebarStyle }),
|
|
||||||
...(input.pdfTemplate && { pdfTemplate: input.pdfTemplate }),
|
...(input.pdfTemplate && { pdfTemplate: input.pdfTemplate }),
|
||||||
...(input.pdfAccentColor && {
|
...(input.pdfAccentColor && {
|
||||||
pdfAccentColor: input.pdfAccentColor,
|
pdfAccentColor: input.pdfAccentColor,
|
||||||
@@ -469,20 +545,21 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
exportData: protectedProcedure.query(async ({ ctx }) => {
|
exportData: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
// Get user info
|
|
||||||
const user = await ctx.db.query.users.findFirst({
|
const user = await ctx.db.query.users.findFirst({
|
||||||
where: eq(users.id, userId),
|
where: eq(users.id, userId),
|
||||||
columns: {
|
columns: {
|
||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
prefersReducedMotion: true,
|
||||||
|
animationSpeedMultiplier: true,
|
||||||
|
theme: true,
|
||||||
|
onboardingCompletedAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all clients
|
|
||||||
const userClients = await ctx.db.query.clients.findMany({
|
const userClients = await ctx.db.query.clients.findMany({
|
||||||
where: eq(clients.createdById, userId),
|
where: eq(clients.createdById, userId),
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
|
||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
phone: true,
|
phone: true,
|
||||||
@@ -492,14 +569,14 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
state: true,
|
state: true,
|
||||||
postalCode: true,
|
postalCode: true,
|
||||||
country: true,
|
country: true,
|
||||||
|
defaultHourlyRate: true,
|
||||||
|
currency: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all businesses
|
|
||||||
const userBusinesses = await ctx.db.query.businesses.findMany({
|
const userBusinesses = await ctx.db.query.businesses.findMany({
|
||||||
where: eq(businesses.createdById, userId),
|
where: eq(businesses.createdById, userId),
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
|
||||||
name: true,
|
name: true,
|
||||||
nickname: true,
|
nickname: true,
|
||||||
email: true,
|
email: true,
|
||||||
@@ -514,24 +591,27 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
taxId: true,
|
taxId: true,
|
||||||
logoUrl: true,
|
logoUrl: true,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
resendApiKey: true,
|
||||||
|
resendDomain: true,
|
||||||
|
emailFromName: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userInvoiceTemplates = await ctx.db.query.invoiceTemplates.findMany({
|
||||||
|
where: eq(invoiceTemplates.createdById, userId),
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
content: true,
|
||||||
|
isDefault: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all invoices with their items
|
|
||||||
const userInvoices = await ctx.db.query.invoices.findMany({
|
const userInvoices = await ctx.db.query.invoices.findMany({
|
||||||
where: eq(invoices.createdById, userId),
|
where: eq(invoices.createdById, userId),
|
||||||
with: {
|
with: {
|
||||||
client: {
|
client: { columns: { name: true } },
|
||||||
columns: {
|
business: { columns: { name: true, nickname: true } },
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
business: {
|
|
||||||
columns: {
|
|
||||||
name: true,
|
|
||||||
nickname: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
items: {
|
items: {
|
||||||
columns: {
|
columns: {
|
||||||
date: true,
|
date: true,
|
||||||
@@ -555,13 +635,61 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Format the data for export
|
const userPayments = await ctx.db.query.invoicePayments.findMany({
|
||||||
const backupData = {
|
where: eq(invoicePayments.createdById, userId),
|
||||||
|
with: {
|
||||||
|
invoice: { columns: { invoiceNumber: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userRecurringInvoices = await ctx.db.query.recurringInvoices.findMany({
|
||||||
|
where: eq(recurringInvoices.createdById, userId),
|
||||||
|
with: {
|
||||||
|
client: { columns: { name: true } },
|
||||||
|
business: { columns: { name: true, nickname: true } },
|
||||||
|
items: {
|
||||||
|
columns: {
|
||||||
|
description: true,
|
||||||
|
hours: true,
|
||||||
|
rate: true,
|
||||||
|
position: true,
|
||||||
|
},
|
||||||
|
orderBy: (items, { asc }) => [
|
||||||
|
asc(items.position),
|
||||||
|
asc(items.createdAt),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userExpenses = await ctx.db.query.expenses.findMany({
|
||||||
|
where: eq(expenses.createdById, userId),
|
||||||
|
with: {
|
||||||
|
client: { columns: { name: true } },
|
||||||
|
business: { columns: { name: true, nickname: true } },
|
||||||
|
invoice: { columns: { invoiceNumber: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userTimeEntries = await ctx.db.query.timeEntries.findMany({
|
||||||
|
where: eq(timeEntries.createdById, userId),
|
||||||
|
with: {
|
||||||
|
client: { columns: { name: true } },
|
||||||
|
invoice: { columns: { invoiceNumber: true } },
|
||||||
|
},
|
||||||
|
orderBy: (entries, { asc }) => [asc(entries.startedAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
exportDate: new Date().toISOString(),
|
exportDate: new Date().toISOString(),
|
||||||
version: "1.0",
|
version: "2.0",
|
||||||
user: {
|
user: {
|
||||||
name: user?.name ?? "",
|
name: user?.name ?? "",
|
||||||
email: user?.email ?? "",
|
email: user?.email ?? "",
|
||||||
|
prefersReducedMotion: user?.prefersReducedMotion ?? false,
|
||||||
|
animationSpeedMultiplier: user?.animationSpeedMultiplier ?? 1,
|
||||||
|
theme: user?.theme ?? "system",
|
||||||
|
onboardingCompletedAt: user?.onboardingCompletedAt ?? null,
|
||||||
},
|
},
|
||||||
clients: userClients.map((client) => ({
|
clients: userClients.map((client) => ({
|
||||||
name: client.name,
|
name: client.name,
|
||||||
@@ -573,6 +701,8 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
state: client.state ?? undefined,
|
state: client.state ?? undefined,
|
||||||
postalCode: client.postalCode ?? undefined,
|
postalCode: client.postalCode ?? undefined,
|
||||||
country: client.country ?? undefined,
|
country: client.country ?? undefined,
|
||||||
|
defaultHourlyRate: client.defaultHourlyRate ?? undefined,
|
||||||
|
currency: client.currency,
|
||||||
})),
|
})),
|
||||||
businesses: userBusinesses.map((business) => ({
|
businesses: userBusinesses.map((business) => ({
|
||||||
name: business.name,
|
name: business.name,
|
||||||
@@ -589,24 +719,82 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
taxId: business.taxId ?? undefined,
|
taxId: business.taxId ?? undefined,
|
||||||
logoUrl: business.logoUrl ?? undefined,
|
logoUrl: business.logoUrl ?? undefined,
|
||||||
isDefault: business.isDefault ?? false,
|
isDefault: business.isDefault ?? false,
|
||||||
|
resendApiKey: business.resendApiKey ?? undefined,
|
||||||
|
resendDomain: business.resendDomain ?? undefined,
|
||||||
|
emailFromName: business.emailFromName ?? undefined,
|
||||||
|
})),
|
||||||
|
invoiceTemplates: userInvoiceTemplates.map((template) => ({
|
||||||
|
name: template.name,
|
||||||
|
type: template.type,
|
||||||
|
content: template.content,
|
||||||
|
isDefault: template.isDefault,
|
||||||
})),
|
})),
|
||||||
invoices: userInvoices.map((invoice) => ({
|
invoices: userInvoices.map((invoice) => ({
|
||||||
invoiceNumber: invoice.invoiceNumber,
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
invoicePrefix: invoice.invoicePrefix ?? "#",
|
||||||
businessName: invoice.business?.name,
|
businessName: invoice.business?.name,
|
||||||
businessNickname: invoice.business?.nickname,
|
businessNickname: invoice.business?.nickname ?? undefined,
|
||||||
clientName: invoice.client.name,
|
clientName: invoice.client.name,
|
||||||
issueDate: invoice.issueDate,
|
issueDate: invoice.issueDate,
|
||||||
dueDate: invoice.dueDate,
|
dueDate: invoice.dueDate,
|
||||||
status: invoice.status,
|
status: invoice.status,
|
||||||
totalAmount: invoice.totalAmount,
|
totalAmount: invoice.totalAmount,
|
||||||
taxRate: invoice.taxRate,
|
taxRate: invoice.taxRate,
|
||||||
|
currency: invoice.currency,
|
||||||
notes: invoice.notes ?? undefined,
|
notes: invoice.notes ?? undefined,
|
||||||
emailMessage: invoice.emailMessage ?? undefined,
|
emailMessage: invoice.emailMessage ?? undefined,
|
||||||
items: invoice.items,
|
items: invoice.items,
|
||||||
})),
|
})),
|
||||||
|
invoicePayments: userPayments.map((payment) => ({
|
||||||
|
invoiceNumber: payment.invoice.invoiceNumber,
|
||||||
|
amount: payment.amount,
|
||||||
|
currency: payment.currency,
|
||||||
|
date: payment.date,
|
||||||
|
method: payment.method,
|
||||||
|
notes: payment.notes ?? undefined,
|
||||||
|
})),
|
||||||
|
recurringInvoices: userRecurringInvoices.map((recurring) => ({
|
||||||
|
name: recurring.name,
|
||||||
|
clientName: recurring.client.name,
|
||||||
|
businessName: recurring.business?.name,
|
||||||
|
businessNickname: recurring.business?.nickname ?? undefined,
|
||||||
|
schedule: recurring.schedule,
|
||||||
|
status: recurring.status,
|
||||||
|
invoicePrefix: recurring.invoicePrefix ?? "#",
|
||||||
|
taxRate: recurring.taxRate,
|
||||||
|
currency: recurring.currency,
|
||||||
|
notes: recurring.notes ?? undefined,
|
||||||
|
emailMessage: recurring.emailMessage ?? undefined,
|
||||||
|
nextDueAt: recurring.nextDueAt,
|
||||||
|
lastGeneratedAt: recurring.lastGeneratedAt ?? undefined,
|
||||||
|
items: recurring.items,
|
||||||
|
})),
|
||||||
|
expenses: userExpenses.map((expense) => ({
|
||||||
|
clientName: expense.client?.name,
|
||||||
|
businessName: expense.business?.name,
|
||||||
|
businessNickname: expense.business?.nickname ?? undefined,
|
||||||
|
invoiceNumber: expense.invoice?.invoiceNumber,
|
||||||
|
date: expense.date,
|
||||||
|
description: expense.description,
|
||||||
|
amount: expense.amount,
|
||||||
|
currency: expense.currency,
|
||||||
|
category: expense.category ?? undefined,
|
||||||
|
billable: expense.billable,
|
||||||
|
reimbursable: expense.reimbursable,
|
||||||
|
taxDeductible: expense.taxDeductible,
|
||||||
|
notes: expense.notes ?? undefined,
|
||||||
|
})),
|
||||||
|
timeEntries: userTimeEntries.map((entry) => ({
|
||||||
|
description: entry.description,
|
||||||
|
clientName: entry.client?.name,
|
||||||
|
invoiceNumber: entry.invoice?.invoiceNumber,
|
||||||
|
startedAt: entry.startedAt,
|
||||||
|
endedAt: entry.endedAt ?? undefined,
|
||||||
|
hours: entry.hours ?? undefined,
|
||||||
|
rate: entry.rate ?? undefined,
|
||||||
|
notes: entry.notes ?? undefined,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
return backupData;
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Import user data (restore)
|
// Import user data (restore)
|
||||||
@@ -615,12 +803,12 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
|
try {
|
||||||
return await ctx.db.transaction(async (tx) => {
|
return await ctx.db.transaction(async (tx) => {
|
||||||
// Create a map to track old to new client IDs
|
|
||||||
const clientIdMap = new Map<string, string>();
|
const clientIdMap = new Map<string, string>();
|
||||||
const businessIdMap = new Map<string, string>();
|
const businessIdMap = new Map<string, string>();
|
||||||
|
const invoiceIdMap = new Map<string, string>();
|
||||||
|
|
||||||
// Import clients
|
|
||||||
for (const clientData of input.clients) {
|
for (const clientData of input.clients) {
|
||||||
const [newClient] = await tx
|
const [newClient] = await tx
|
||||||
.insert(clients)
|
.insert(clients)
|
||||||
@@ -635,7 +823,6 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import businesses
|
|
||||||
for (const businessData of input.businesses) {
|
for (const businessData of input.businesses) {
|
||||||
const [newBusiness] = await tx
|
const [newBusiness] = await tx
|
||||||
.insert(businesses)
|
.insert(businesses)
|
||||||
@@ -653,26 +840,28 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import invoices
|
for (const templateData of input.invoiceTemplates) {
|
||||||
|
await tx.insert(invoiceTemplates).values({
|
||||||
|
...templateData,
|
||||||
|
createdById: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const invoiceData of input.invoices) {
|
for (const invoiceData of input.invoices) {
|
||||||
const clientId = clientIdMap.get(invoiceData.clientName);
|
const clientId = clientIdMap.get(invoiceData.clientName);
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
throw new Error(`Client ${invoiceData.clientName} not found`);
|
backupImportError(
|
||||||
|
`Client "${invoiceData.clientName}" not found for invoice ${invoiceData.invoiceNumber}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const businessId = invoiceData.businessNickname
|
const businessId = resolveBusinessId(invoiceData, businessIdMap);
|
||||||
? (businessIdMap.get(invoiceData.businessNickname) ??
|
|
||||||
(invoiceData.businessName
|
|
||||||
? (businessIdMap.get(invoiceData.businessName) ?? null)
|
|
||||||
: null))
|
|
||||||
: invoiceData.businessName
|
|
||||||
? (businessIdMap.get(invoiceData.businessName) ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const [newInvoice] = await tx
|
const [newInvoice] = await tx
|
||||||
.insert(invoices)
|
.insert(invoices)
|
||||||
.values({
|
.values({
|
||||||
invoiceNumber: invoiceData.invoiceNumber,
|
invoiceNumber: invoiceData.invoiceNumber,
|
||||||
|
invoicePrefix: invoiceData.invoicePrefix,
|
||||||
businessId,
|
businessId,
|
||||||
clientId,
|
clientId,
|
||||||
issueDate: invoiceData.issueDate,
|
issueDate: invoiceData.issueDate,
|
||||||
@@ -680,14 +869,17 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
status: invoiceData.status,
|
status: invoiceData.status,
|
||||||
totalAmount: invoiceData.totalAmount,
|
totalAmount: invoiceData.totalAmount,
|
||||||
taxRate: invoiceData.taxRate,
|
taxRate: invoiceData.taxRate,
|
||||||
|
currency: invoiceData.currency,
|
||||||
notes: invoiceData.notes,
|
notes: invoiceData.notes,
|
||||||
emailMessage: invoiceData.emailMessage,
|
emailMessage: invoiceData.emailMessage,
|
||||||
createdById: userId,
|
createdById: userId,
|
||||||
})
|
})
|
||||||
.returning({ id: invoices.id });
|
.returning({ id: invoices.id });
|
||||||
|
|
||||||
if (newInvoice && invoiceData.items.length > 0) {
|
if (newInvoice) {
|
||||||
// Import invoice items
|
invoiceIdMap.set(invoiceData.invoiceNumber, newInvoice.id);
|
||||||
|
|
||||||
|
if (invoiceData.items.length > 0) {
|
||||||
await tx.insert(invoiceItems).values(
|
await tx.insert(invoiceItems).values(
|
||||||
invoiceData.items.map((item) => ({
|
invoiceData.items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -696,20 +888,197 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const paymentData of input.invoicePayments) {
|
||||||
|
const invoiceId = invoiceIdMap.get(paymentData.invoiceNumber);
|
||||||
|
if (!invoiceId) {
|
||||||
|
backupImportError(
|
||||||
|
`Invoice "${paymentData.invoiceNumber}" not found for payment`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(invoicePayments).values({
|
||||||
|
invoiceId,
|
||||||
|
amount: paymentData.amount,
|
||||||
|
currency: paymentData.currency,
|
||||||
|
date: paymentData.date,
|
||||||
|
method: paymentData.method,
|
||||||
|
notes: paymentData.notes,
|
||||||
|
createdById: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const recurringData of input.recurringInvoices) {
|
||||||
|
const clientId = clientIdMap.get(recurringData.clientName);
|
||||||
|
if (!clientId) {
|
||||||
|
backupImportError(
|
||||||
|
`Client "${recurringData.clientName}" not found for recurring invoice "${recurringData.name}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessId = resolveBusinessId(recurringData, businessIdMap);
|
||||||
|
|
||||||
|
const [newRecurring] = await tx
|
||||||
|
.insert(recurringInvoices)
|
||||||
|
.values({
|
||||||
|
name: recurringData.name,
|
||||||
|
clientId,
|
||||||
|
businessId,
|
||||||
|
schedule: recurringData.schedule,
|
||||||
|
status: recurringData.status,
|
||||||
|
invoicePrefix: recurringData.invoicePrefix,
|
||||||
|
taxRate: recurringData.taxRate,
|
||||||
|
currency: recurringData.currency,
|
||||||
|
notes: recurringData.notes,
|
||||||
|
emailMessage: recurringData.emailMessage,
|
||||||
|
nextDueAt: recurringData.nextDueAt,
|
||||||
|
lastGeneratedAt: recurringData.lastGeneratedAt,
|
||||||
|
createdById: userId,
|
||||||
|
})
|
||||||
|
.returning({ id: recurringInvoices.id });
|
||||||
|
|
||||||
|
if (newRecurring && recurringData.items.length > 0) {
|
||||||
|
await tx.insert(recurringInvoiceItems).values(
|
||||||
|
recurringData.items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
recurringInvoiceId: newRecurring.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const expenseData of input.expenses) {
|
||||||
|
const clientId = expenseData.clientName
|
||||||
|
? (clientIdMap.get(expenseData.clientName) ?? null)
|
||||||
|
: null;
|
||||||
|
if (expenseData.clientName && !clientId) {
|
||||||
|
backupImportError(
|
||||||
|
`Client "${expenseData.clientName}" not found for expense "${expenseData.description}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessId = resolveBusinessId(expenseData, businessIdMap);
|
||||||
|
const invoiceId = expenseData.invoiceNumber
|
||||||
|
? (invoiceIdMap.get(expenseData.invoiceNumber) ?? null)
|
||||||
|
: null;
|
||||||
|
if (expenseData.invoiceNumber && !invoiceId) {
|
||||||
|
backupImportError(
|
||||||
|
`Invoice "${expenseData.invoiceNumber}" not found for expense "${expenseData.description}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(expenses).values({
|
||||||
|
clientId,
|
||||||
|
businessId,
|
||||||
|
invoiceId,
|
||||||
|
date: expenseData.date,
|
||||||
|
description: expenseData.description,
|
||||||
|
amount: expenseData.amount,
|
||||||
|
currency: expenseData.currency,
|
||||||
|
category: expenseData.category,
|
||||||
|
billable: expenseData.billable,
|
||||||
|
reimbursable: expenseData.reimbursable,
|
||||||
|
taxDeductible: expenseData.taxDeductible,
|
||||||
|
notes: expenseData.notes,
|
||||||
|
createdById: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRunningEntry = await tx.query.timeEntries.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(timeEntries.createdById, userId),
|
||||||
|
isNull(timeEntries.endedAt),
|
||||||
|
),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entryData of input.timeEntries) {
|
||||||
|
const clientId = entryData.clientName
|
||||||
|
? (clientIdMap.get(entryData.clientName) ?? null)
|
||||||
|
: null;
|
||||||
|
if (entryData.clientName && !clientId) {
|
||||||
|
backupImportError(
|
||||||
|
`Client "${entryData.clientName}" not found for time entry`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceId = entryData.invoiceNumber
|
||||||
|
? (invoiceIdMap.get(entryData.invoiceNumber) ?? null)
|
||||||
|
: null;
|
||||||
|
if (entryData.invoiceNumber && !invoiceId) {
|
||||||
|
backupImportError(
|
||||||
|
`Invoice "${entryData.invoiceNumber}" not found for time entry`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRunningImport = entryData.endedAt == null;
|
||||||
|
const endedAt =
|
||||||
|
isRunningImport && existingRunningEntry
|
||||||
|
? entryData.startedAt
|
||||||
|
: entryData.endedAt;
|
||||||
|
|
||||||
|
await tx.insert(timeEntries).values({
|
||||||
|
description: entryData.description,
|
||||||
|
clientId,
|
||||||
|
invoiceId,
|
||||||
|
startedAt: entryData.startedAt,
|
||||||
|
endedAt,
|
||||||
|
hours: entryData.hours,
|
||||||
|
rate: entryData.rate,
|
||||||
|
notes: entryData.notes,
|
||||||
|
createdById: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
...(input.user.prefersReducedMotion !== undefined && {
|
||||||
|
prefersReducedMotion: input.user.prefersReducedMotion,
|
||||||
|
}),
|
||||||
|
...(input.user.animationSpeedMultiplier !== undefined && {
|
||||||
|
animationSpeedMultiplier: input.user.animationSpeedMultiplier,
|
||||||
|
}),
|
||||||
|
...(input.user.theme !== undefined && { theme: input.user.theme }),
|
||||||
|
...(input.user.onboardingCompletedAt !== undefined && {
|
||||||
|
onboardingCompletedAt: input.user.onboardingCompletedAt,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
imported: {
|
imported: {
|
||||||
clients: input.clients.length,
|
clients: input.clients.length,
|
||||||
businesses: input.businesses.length,
|
businesses: input.businesses.length,
|
||||||
|
invoiceTemplates: input.invoiceTemplates.length,
|
||||||
invoices: input.invoices.length,
|
invoices: input.invoices.length,
|
||||||
items: input.invoices.reduce(
|
invoiceItems: input.invoices.reduce(
|
||||||
(sum, inv) => sum + inv.items.length,
|
(sum, inv) => sum + inv.items.length,
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
|
invoicePayments: input.invoicePayments.length,
|
||||||
|
recurringInvoices: input.recurringInvoices.length,
|
||||||
|
recurringInvoiceItems: input.recurringInvoices.reduce(
|
||||||
|
(sum, inv) => sum + inv.items.length,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
expenses: input.expenses.length,
|
||||||
|
timeEntries: input.timeEntries.length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message:
|
||||||
|
error instanceof Error ? error.message : "Import failed unexpectedly",
|
||||||
|
});
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Get data statistics
|
// Get data statistics
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
import {
|
import {
|
||||||
computeTrackedHours,
|
computeTrackedHours,
|
||||||
|
resolveBillingDescription,
|
||||||
type ClockOutOutcome,
|
type ClockOutOutcome,
|
||||||
} from "~/lib/time-clock";
|
} from "~/lib/time-clock";
|
||||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||||
@@ -457,12 +458,13 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const endedAt = new Date();
|
const endedAt = new Date();
|
||||||
const hours = computeHours(entry.startedAt, endedAt);
|
const hours = computeHours(entry.startedAt, endedAt);
|
||||||
const description = input?.description?.trim() ?? entry.description;
|
const rawDescription = input?.description?.trim() ?? entry.description?.trim() ?? "";
|
||||||
|
const billingDescription = resolveBillingDescription(rawDescription);
|
||||||
const rate = entry.rate ?? 0;
|
const rate = entry.rate ?? 0;
|
||||||
|
|
||||||
const [updated] = await ctx.db
|
const [updated] = await ctx.db
|
||||||
.update(timeEntries)
|
.update(timeEntries)
|
||||||
.set({ endedAt, hours, description, updatedAt: new Date() })
|
.set({ endedAt, hours, description: rawDescription, updatedAt: new Date() })
|
||||||
.where(eq(timeEntries.id, entry.id))
|
.where(eq(timeEntries.id, entry.id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -478,7 +480,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
ctx.session.user.id,
|
ctx.session.user.id,
|
||||||
entry.invoiceId,
|
entry.invoiceId,
|
||||||
updated.id,
|
updated.id,
|
||||||
description,
|
billingDescription,
|
||||||
hours,
|
hours,
|
||||||
rate,
|
rate,
|
||||||
endedAt,
|
endedAt,
|
||||||
@@ -490,7 +492,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
ctx.session.user.id,
|
ctx.session.user.id,
|
||||||
entry.clientId,
|
entry.clientId,
|
||||||
updated.id,
|
updated.id,
|
||||||
description,
|
billingDescription,
|
||||||
hours,
|
hours,
|
||||||
rate,
|
rate,
|
||||||
endedAt,
|
endedAt,
|
||||||
|
|||||||
@@ -133,17 +133,12 @@ export const createTRPCRouter = t.router;
|
|||||||
*/
|
*/
|
||||||
const timingMiddleware = t.middleware(async ({ next, path }) => {
|
const timingMiddleware = t.middleware(async ({ next, path }) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
const result = await next();
|
||||||
|
const end = Date.now();
|
||||||
|
|
||||||
if (t._config.isDev) {
|
if (t._config.isDev) {
|
||||||
// artificial delay in dev
|
|
||||||
const waitMs = Math.floor(Math.random() * 400) + 100;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await next();
|
|
||||||
|
|
||||||
const end = Date.now();
|
|
||||||
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
|
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-25
@@ -32,37 +32,13 @@ export const users = createTable("user", (d) => ({
|
|||||||
// Custom fields
|
// Custom fields
|
||||||
prefersReducedMotion: d.boolean().default(false).notNull(),
|
prefersReducedMotion: d.boolean().default(false).notNull(),
|
||||||
animationSpeedMultiplier: d.real().default(1).notNull(),
|
animationSpeedMultiplier: d.real().default(1).notNull(),
|
||||||
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
|
|
||||||
customColor: d.varchar({ length: 50 }),
|
|
||||||
theme: d.varchar({ length: 20 }).default("system").notNull(),
|
theme: d.varchar({ length: 20 }).default("system").notNull(),
|
||||||
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
|
|
||||||
fontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
|
||||||
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
|
||||||
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
|
||||||
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
|
|
||||||
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
|
|
||||||
role: d.varchar({ length: 20 }).default("user").notNull(),
|
role: d.varchar({ length: 20 }).default("user").notNull(),
|
||||||
|
onboardingCompletedAt: d.timestamp(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const platformSettings = createTable("platform_setting", (d) => ({
|
export const platformSettings = createTable("platform_setting", (d) => ({
|
||||||
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
|
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
|
||||||
brandName: d.varchar({ length: 100 }).default("beenvoice").notNull(),
|
|
||||||
brandTagline: d
|
|
||||||
.varchar({ length: 255 })
|
|
||||||
.default(
|
|
||||||
"Simple and efficient invoicing for freelancers and small businesses",
|
|
||||||
)
|
|
||||||
.notNull(),
|
|
||||||
brandLogoText: d.varchar({ length: 100 }).default("beenvoice").notNull(),
|
|
||||||
brandIcon: d.varchar({ length: 20 }).default("$").notNull(),
|
|
||||||
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
|
|
||||||
customColor: d.varchar({ length: 50 }),
|
|
||||||
theme: d.varchar({ length: 20 }).default("system").notNull(),
|
|
||||||
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
|
|
||||||
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
|
||||||
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
|
||||||
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
|
|
||||||
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
|
|
||||||
pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(),
|
pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(),
|
||||||
pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(),
|
pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(),
|
||||||
pdfFooterText: d
|
pdfFooterText: d
|
||||||
|
|||||||
+21
-576
@@ -3,218 +3,37 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
|
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
|
||||||
|
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
/* #FFFFFF */
|
|
||||||
--foreground: 240 10% 3.9%;
|
--foreground: 240 10% 3.9%;
|
||||||
/* #09090B */
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
/* #FFFFFF */
|
|
||||||
--card-foreground: 240 10% 3.9%;
|
--card-foreground: 240 10% 3.9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 240 10% 3.9%;
|
--popover-foreground: 240 10% 3.9%;
|
||||||
--primary: 240 5.9% 10%;
|
--primary: 240 5.9% 10%;
|
||||||
/* #18181B */
|
|
||||||
--primary-foreground: 0 0% 98%;
|
--primary-foreground: 0 0% 98%;
|
||||||
/* #FAFAFA */
|
|
||||||
--secondary: 240 4.8% 90%;
|
--secondary: 240 4.8% 90%;
|
||||||
/* #E4E4E7 (Darkened for contrast) */
|
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
--muted: 240 4.8% 95.9%;
|
--muted: 240 4.8% 95.9%;
|
||||||
/* #F4F4F5 */
|
|
||||||
--muted-foreground: 240 3.8% 46.1%;
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
--accent: 240 4.8% 95.9%;
|
--accent: 240 4.8% 95.9%;
|
||||||
--accent-foreground: 240 5.9% 10%;
|
--accent-foreground: 240 5.9% 10%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 240 5.9% 90%;
|
--border: 240 5.9% 90%;
|
||||||
/* #E4E4E7 */
|
|
||||||
--input: 240 5.9% 90%;
|
--input: 240 5.9% 90%;
|
||||||
--ring: 240 10% 3.9%;
|
--ring: 240 10% 3.9%;
|
||||||
--radius: 1rem;
|
--radius: 1rem;
|
||||||
/* 16px Global Radius */
|
--dashboard-mobile-header-height: 4rem;
|
||||||
}
|
--dashboard-mobile-header-gap: 0.75rem;
|
||||||
|
|
||||||
:root[data-interface-theme="shadcn"] {
|
|
||||||
--primary: 240 5.9% 10%;
|
|
||||||
--primary-foreground: 0 0% 98%;
|
|
||||||
--secondary: 240 4.8% 95.9%;
|
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
|
||||||
--accent: 240 4.8% 95.9%;
|
|
||||||
--accent-foreground: 240 5.9% 10%;
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="beenvoice"] {
|
|
||||||
--secondary: 240 4.8% 90%;
|
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
|
||||||
--radius: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] {
|
|
||||||
--background: 0 0% 100%;
|
|
||||||
--foreground: 210 24% 8%;
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 210 24% 8%;
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 210 24% 8%;
|
|
||||||
--primary: 203 100% 18%;
|
|
||||||
--primary-foreground: 46 100% 91%;
|
|
||||||
--secondary: 47 100% 50%;
|
|
||||||
--secondary-foreground: 210 24% 8%;
|
|
||||||
--muted: 199 73% 91%;
|
|
||||||
--muted-foreground: 203 52% 24%;
|
|
||||||
--accent: 47 100% 50%;
|
|
||||||
--accent-foreground: 210 24% 8%;
|
|
||||||
--border: 203 45% 42%;
|
|
||||||
--input: 203 45% 42%;
|
|
||||||
--ring: 203 100% 18%;
|
|
||||||
--radius: 0rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger-aero"] {
|
|
||||||
--background: 190 76% 96%;
|
|
||||||
--foreground: 205 50% 12%;
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 205 50% 12%;
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 205 50% 12%;
|
|
||||||
--primary: 201 100% 37%;
|
|
||||||
--primary-foreground: 0 0% 100%;
|
|
||||||
--secondary: 104 55% 55%;
|
|
||||||
--secondary-foreground: 205 50% 12%;
|
|
||||||
--muted: 190 56% 90%;
|
|
||||||
--muted-foreground: 205 32% 32%;
|
|
||||||
--accent: 104 55% 55%;
|
|
||||||
--accent-foreground: 205 50% 12%;
|
|
||||||
--border: 195 48% 74%;
|
|
||||||
--input: 195 48% 74%;
|
|
||||||
--ring: 201 100% 37%;
|
|
||||||
--radius: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="minimal"] {
|
|
||||||
--background: 0 0% 100%;
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--primary: 240 5.9% 10%;
|
|
||||||
--primary-foreground: 0 0% 98%;
|
|
||||||
--secondary: 240 4.8% 96.5%;
|
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
|
||||||
--muted: 240 4.8% 97%;
|
|
||||||
--accent: 240 4.8% 96%;
|
|
||||||
--accent-foreground: 240 5.9% 10%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="editorial"] {
|
|
||||||
--background: 36 33% 98%;
|
|
||||||
--card: 36 33% 99%;
|
|
||||||
--popover: 36 33% 99%;
|
|
||||||
--primary: 346.8 77.2% 49.8%;
|
|
||||||
--primary-foreground: 355.7 100% 97.3%;
|
|
||||||
--secondary: 30 18% 91%;
|
|
||||||
--secondary-foreground: 24 10% 10%;
|
|
||||||
--muted: 30 20% 94%;
|
|
||||||
--accent: 346.8 77.2% 49.8%;
|
|
||||||
--accent-foreground: 355.7 100% 97.3%;
|
|
||||||
--border: 30 15% 86%;
|
|
||||||
--input: 30 15% 86%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-body-font="brand"],
|
|
||||||
:root[data-body-font="inter"] {
|
|
||||||
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-body-font="frutiger"] {
|
|
||||||
--app-font-sans: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-body-font="platform"] {
|
|
||||||
--app-font-sans:
|
|
||||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
||||||
sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-body-font="serif"] {
|
|
||||||
--app-font-sans:
|
|
||||||
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-heading-font="brand"],
|
|
||||||
:root[data-heading-font="serif"] {
|
|
||||||
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-heading-font="frutiger"] {
|
|
||||||
--app-font-heading: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-heading-font="platform"] {
|
|
||||||
--app-font-heading:
|
|
||||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
||||||
sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-heading-font="inter"] {
|
|
||||||
--app-font-heading: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-font="brand"]:not([data-body-font]) {
|
|
||||||
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
|
||||||
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-font="frutiger"]:not([data-body-font]) {
|
|
||||||
--app-font-sans: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
|
|
||||||
--app-font-heading: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-font="platform"]:not([data-body-font]) {
|
|
||||||
--app-font-sans:
|
|
||||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
||||||
sans-serif;
|
|
||||||
--app-font-heading:
|
|
||||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
||||||
sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-font="inter"]:not([data-body-font]) {
|
|
||||||
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
|
||||||
--app-font-heading: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-font="serif"]:not([data-body-font]) {
|
|
||||||
--app-font-sans:
|
|
||||||
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
||||||
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-radius="none"] {
|
|
||||||
--radius: 0rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-radius="sm"] {
|
|
||||||
--radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-radius="md"] {
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-radius="lg"] {
|
|
||||||
--radius: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-radius="xl"] {
|
|
||||||
--radius: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-color-mode="dark"],
|
:root[data-color-mode="dark"],
|
||||||
:root.dark {
|
:root.dark {
|
||||||
--background: 240 10% 3.9%;
|
--background: 240 10% 3.9%;
|
||||||
/* #09090B */
|
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
/* #FAFAFA */
|
|
||||||
--card: 240 10% 3.9%;
|
--card: 240 10% 3.9%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
--popover: 240 10% 3.9%;
|
--popover: 240 10% 3.9%;
|
||||||
@@ -222,7 +41,6 @@
|
|||||||
--primary: 0 0% 98%;
|
--primary: 0 0% 98%;
|
||||||
--primary-foreground: 240 5.9% 10%;
|
--primary-foreground: 240 5.9% 10%;
|
||||||
--secondary: 240 3.7% 20%;
|
--secondary: 240 3.7% 20%;
|
||||||
/* #27272A */
|
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
--muted: 240 3.7% 15.9%;
|
--muted: 240 3.7% 15.9%;
|
||||||
--muted-foreground: 240 5% 64.9%;
|
--muted-foreground: 240 5% 64.9%;
|
||||||
@@ -231,7 +49,6 @@
|
|||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 240 3.7% 15.9%;
|
--border: 240 3.7% 15.9%;
|
||||||
/* #27272A */
|
|
||||||
--input: 240 3.7% 15.9%;
|
--input: 240 3.7% 15.9%;
|
||||||
--ring: 240 4.9% 83.9%;
|
--ring: 240 4.9% 83.9%;
|
||||||
}
|
}
|
||||||
@@ -239,9 +56,7 @@
|
|||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-color-mode="light"]) {
|
:root:not([data-color-mode="light"]) {
|
||||||
--background: 240 10% 3.9%;
|
--background: 240 10% 3.9%;
|
||||||
/* #09090B */
|
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
/* #FAFAFA */
|
|
||||||
--card: 240 10% 3.9%;
|
--card: 240 10% 3.9%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
--popover: 240 10% 3.9%;
|
--popover: 240 10% 3.9%;
|
||||||
@@ -249,7 +64,6 @@
|
|||||||
--primary: 0 0% 98%;
|
--primary: 0 0% 98%;
|
||||||
--primary-foreground: 240 5.9% 10%;
|
--primary-foreground: 240 5.9% 10%;
|
||||||
--secondary: 240 3.7% 20%;
|
--secondary: 240 3.7% 20%;
|
||||||
/* #27272A */
|
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
--muted: 240 3.7% 15.9%;
|
--muted: 240 3.7% 15.9%;
|
||||||
--muted-foreground: 240 5% 64.9%;
|
--muted-foreground: 240 5% 64.9%;
|
||||||
@@ -258,110 +72,10 @@
|
|||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 240 3.7% 15.9%;
|
--border: 240 3.7% 15.9%;
|
||||||
/* #27272A */
|
|
||||||
--input: 240 3.7% 15.9%;
|
--input: 240 3.7% 15.9%;
|
||||||
--ring: 240 4.9% 83.9%;
|
--ring: 240 4.9% 83.9%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-color-theme="slate"] {
|
|
||||||
--primary: 240 5.9% 10%;
|
|
||||||
--primary-foreground: 0 0% 98%;
|
|
||||||
--accent: 240 4.8% 95.9%;
|
|
||||||
--accent-foreground: 240 5.9% 10%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-color-theme="blue"] {
|
|
||||||
--primary: 221.2 83.2% 53.3%;
|
|
||||||
--primary-foreground: 210 40% 98%;
|
|
||||||
--accent: 217.2 91.2% 59.8%;
|
|
||||||
--accent-foreground: 210 40% 98%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-color-theme="green"] {
|
|
||||||
--primary: 142.1 76.2% 36.3%;
|
|
||||||
--primary-foreground: 355.7 100% 97.3%;
|
|
||||||
--accent: 142.1 70.6% 45.3%;
|
|
||||||
--accent-foreground: 355.7 100% 97.3%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-color-theme="rose"] {
|
|
||||||
--primary: 346.8 77.2% 49.8%;
|
|
||||||
--primary-foreground: 355.7 100% 97.3%;
|
|
||||||
--accent: 346.8 77.2% 49.8%;
|
|
||||||
--accent-foreground: 355.7 100% 97.3%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-color-theme="orange"] {
|
|
||||||
--primary: 24.6 95% 53.1%;
|
|
||||||
--primary-foreground: 60 9.1% 97.8%;
|
|
||||||
--accent: 20.5 90.2% 48.2%;
|
|
||||||
--accent-foreground: 60 9.1% 97.8%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-color-theme="custom"] {
|
|
||||||
--primary: var(--custom-primary, 142.1 76.2% 36.3%);
|
|
||||||
--primary-foreground: 355.7 100% 97.3%;
|
|
||||||
--accent: var(--custom-primary, 142.1 76.2% 36.3%);
|
|
||||||
--accent-foreground: 355.7 100% 97.3%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] {
|
|
||||||
--background: 0 0% 100%;
|
|
||||||
--foreground: 210 24% 8%;
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 210 24% 8%;
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 210 24% 8%;
|
|
||||||
--primary: 203 100% 18%;
|
|
||||||
--primary-foreground: 46 100% 91%;
|
|
||||||
--accent: 47 100% 50%;
|
|
||||||
--accent-foreground: 210 24% 8%;
|
|
||||||
--secondary: 47 100% 50%;
|
|
||||||
--secondary-foreground: 210 24% 8%;
|
|
||||||
--muted: 199 73% 91%;
|
|
||||||
--muted-foreground: 203 52% 24%;
|
|
||||||
--border: 203 45% 42%;
|
|
||||||
--input: 203 45% 42%;
|
|
||||||
--ring: 203 100% 18%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger-aero"] {
|
|
||||||
--background: 190 76% 96%;
|
|
||||||
--foreground: 205 50% 12%;
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 205 50% 12%;
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 205 50% 12%;
|
|
||||||
--primary: 201 100% 37%;
|
|
||||||
--primary-foreground: 0 0% 100%;
|
|
||||||
--accent: 104 55% 55%;
|
|
||||||
--accent-foreground: 205 50% 12%;
|
|
||||||
--secondary: 104 55% 55%;
|
|
||||||
--secondary-foreground: 205 50% 12%;
|
|
||||||
--muted: 190 56% 90%;
|
|
||||||
--muted-foreground: 205 32% 32%;
|
|
||||||
--border: 195 48% 74%;
|
|
||||||
--input: 195 48% 74%;
|
|
||||||
--ring: 201 100% 37%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-color-mode="dark"][data-color-theme="slate"],
|
|
||||||
:root.dark[data-color-theme="slate"] {
|
|
||||||
--primary: 0 0% 98%;
|
|
||||||
--primary-foreground: 240 5.9% 10%;
|
|
||||||
--accent: 240 3.7% 15.9%;
|
|
||||||
--accent-foreground: 0 0% 98%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root:not([data-color-mode="light"])[data-color-theme="slate"] {
|
|
||||||
--primary: 0 0% 98%;
|
|
||||||
--primary-foreground: 240 5.9% 10%;
|
|
||||||
--accent: 240 3.7% 15.9%;
|
|
||||||
--accent-foreground: 0 0% 98%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -415,298 +129,48 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
:root[data-interface-theme="shadcn"] .brand-background,
|
.dashboard-mobile-header {
|
||||||
:root[data-interface-theme="minimal"] .brand-background {
|
min-height: var(--dashboard-mobile-header-height);
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] .brand-background {
|
.dashboard-content-shell {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger-aero"] .brand-background {
|
|
||||||
display: flex;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 18% 22%, hsl(104 55% 55% / 0.35), transparent 32%),
|
|
||||||
radial-gradient(circle at 82% 18%, hsl(190 88% 66% / 0.42), transparent 30%),
|
|
||||||
linear-gradient(180deg, hsl(195 100% 94% / 0.95), hsl(0 0% 100% / 0.35));
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] .dashboard-content-shell {
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
padding-top: 4rem;
|
padding-top: calc(
|
||||||
|
var(--dashboard-mobile-header-height) + var(--dashboard-mobile-header-gap)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
:root[data-interface-theme="frutiger"] .dashboard-content-shell {
|
.dashboard-content-shell {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] .bg-dashboard {
|
.platform-header-content {
|
||||||
background: hsl(var(--background));
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger-aero"] .bg-dashboard {
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 78% 8%, hsl(104 55% 55% / 0.22), transparent 26rem),
|
|
||||||
linear-gradient(180deg, hsl(190 76% 96%) 0%, hsl(0 0% 100%) 72%);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] aside {
|
|
||||||
border-color: hsl(var(--primary));
|
|
||||||
background-color: hsl(var(--primary));
|
|
||||||
color: hsl(var(--primary-foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] aside [class*="text-muted-foreground"] {
|
|
||||||
color: hsl(var(--primary-foreground) / 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] aside a {
|
|
||||||
color: hsl(var(--primary-foreground) / 0.86);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] aside a:hover,
|
|
||||||
:root[data-interface-theme="frutiger"] aside a[data-active="true"] {
|
|
||||||
background-color: hsl(var(--accent));
|
|
||||||
color: hsl(var(--accent-foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] aside button {
|
|
||||||
color: hsl(var(--primary-foreground) / 0.82);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] .dashboard-mobile-header {
|
|
||||||
border-color: hsl(var(--primary));
|
|
||||||
background-color: hsl(var(--accent));
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
backdrop-filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] .platform-header-surface {
|
|
||||||
border-color: hsl(var(--primary));
|
|
||||||
border-top: 0.5rem solid hsl(var(--primary));
|
|
||||||
background-color: hsl(var(--accent));
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] .platform-header-gradient {
|
|
||||||
display: none !important;
|
|
||||||
background: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] .platform-header-surface .text-primary,
|
|
||||||
:root[data-interface-theme="frutiger"] .platform-header-surface [class*="text-primary"],
|
|
||||||
:root[data-interface-theme="frutiger"] .dashboard-mobile-header .text-primary,
|
|
||||||
:root[data-interface-theme="frutiger"] .dashboard-mobile-header [class*="text-primary"] {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] .platform-header-surface .text-muted-foreground,
|
|
||||||
:root[data-interface-theme="frutiger"] .platform-header-surface [class*="text-muted-foreground"] {
|
|
||||||
color: hsl(var(--foreground) / 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger-aero"] aside {
|
|
||||||
border-color: hsl(190 88% 66% / 0.55);
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, hsl(201 100% 37% / 0.82), hsl(190 88% 45% / 0.72)),
|
|
||||||
hsl(201 100% 37% / 0.78);
|
|
||||||
color: hsl(var(--primary-foreground));
|
|
||||||
box-shadow: 0 18px 36px -20px hsl(201 100% 22% / 0.55);
|
|
||||||
backdrop-filter: blur(18px) saturate(1.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger-aero"] aside [class*="text-muted-foreground"] {
|
|
||||||
color: hsl(var(--primary-foreground) / 0.76);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger-aero"] aside a {
|
|
||||||
color: hsl(var(--primary-foreground) / 0.88);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger-aero"] aside a:hover,
|
|
||||||
:root[data-interface-theme="frutiger-aero"] aside a[data-active="true"] {
|
|
||||||
background-color: hsl(0 0% 100% / 0.92);
|
|
||||||
color: hsl(var(--primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger-aero"] .dashboard-mobile-header {
|
|
||||||
border-color: hsl(190 88% 66% / 0.55);
|
|
||||||
background-color: hsl(0 0% 100% / 0.78);
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
backdrop-filter: blur(18px) saturate(1.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger-aero"] .platform-header-surface {
|
|
||||||
border-color: hsl(190 88% 66% / 0.6);
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, hsl(0 0% 100% / 0.88), hsl(190 88% 92% / 0.72)),
|
|
||||||
hsl(0 0% 100% / 0.72);
|
|
||||||
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.9),
|
|
||||||
0 18px 36px -28px hsl(201 100% 37% / 0.6);
|
|
||||||
backdrop-filter: blur(16px) saturate(1.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger-aero"] .platform-header-gradient {
|
|
||||||
display: block;
|
|
||||||
background: radial-gradient(circle at 92% 10%, hsl(104 55% 55% / 0.45), transparent 34%),
|
|
||||||
linear-gradient(135deg, hsl(190 88% 66% / 0.28), transparent 48%);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] [data-slot="card"],
|
|
||||||
:root[data-interface-theme="frutiger"] [data-slot="dialog-content"],
|
|
||||||
:root[data-interface-theme="frutiger"] [data-slot="popover-content"],
|
|
||||||
:root[data-interface-theme="frutiger"] [data-slot="select-content"] {
|
|
||||||
border-color: hsl(var(--primary) / 0.45);
|
|
||||||
border-radius: 0;
|
|
||||||
background-color: hsl(var(--card));
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] [data-slot="card"] {
|
|
||||||
border-top: 0.35rem solid hsl(var(--accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger-aero"] [data-slot="card"] {
|
|
||||||
border-color: hsl(190 88% 66% / 0.45);
|
|
||||||
background-color: hsl(0 0% 100% / 0.78);
|
|
||||||
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.9),
|
|
||||||
0 16px 34px -30px hsl(201 100% 37% / 0.7);
|
|
||||||
backdrop-filter: blur(14px) saturate(1.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] [data-slot="button"],
|
|
||||||
:root[data-interface-theme="frutiger"] button,
|
|
||||||
:root[data-interface-theme="frutiger"] input,
|
|
||||||
:root[data-interface-theme="frutiger"] textarea,
|
|
||||||
:root[data-interface-theme="frutiger"] [role="combobox"] {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] .button-hover:hover,
|
|
||||||
:root[data-interface-theme="frutiger"] .card-hover:hover {
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="frutiger"] [data-slot="card-header"],
|
|
||||||
:root[data-interface-theme="frutiger"] [data-slot="card-content"],
|
|
||||||
:root[data-interface-theme="frutiger"] [data-slot="card-footer"] {
|
|
||||||
padding-inline: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="minimal"] [data-slot="card"] {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
border-radius: 0;
|
|
||||||
border-top-color: hsl(var(--border));
|
|
||||||
box-shadow: none;
|
|
||||||
backdrop-filter: none;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="minimal"] [data-slot="card"] + [data-slot="card"],
|
|
||||||
:root[data-interface-theme="minimal"] .form-section + .form-section {
|
|
||||||
border-top: 1px solid hsl(var(--border));
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="minimal"] [data-slot="card-header"],
|
|
||||||
:root[data-interface-theme="minimal"] [data-slot="card-content"],
|
|
||||||
:root[data-interface-theme="minimal"] [data-slot="card-footer"] {
|
|
||||||
padding-inline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="minimal"] [data-slot="card-header"] {
|
|
||||||
padding-top: 0.75rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="minimal"] [data-slot="card-content"] {
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="minimal"] .page-enter,
|
|
||||||
:root[data-interface-theme="minimal"] [class*="space-y-8"],
|
|
||||||
:root[data-interface-theme="minimal"] [class*="space-y-6"] {
|
|
||||||
row-gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="minimal"]
|
|
||||||
[class*="space-y-8"]
|
|
||||||
> :not([hidden])
|
|
||||||
~ :not([hidden]),
|
|
||||||
:root[data-interface-theme="minimal"]
|
|
||||||
[class*="space-y-6"]
|
|
||||||
> :not([hidden])
|
|
||||||
~ :not([hidden]) {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="minimal"] [class*="gap-6"] {
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="minimal"] .platform-header-surface {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
backdrop-filter: none;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="minimal"] .platform-header-content {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="minimal"] .platform-header-gradient {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="minimal"] .bg-dashboard {
|
|
||||||
background-color: hsl(var(--background));
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="beenvoice"] .dashboard-content-shell {
|
|
||||||
padding: 0.75rem;
|
|
||||||
padding-top: 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
:root[data-interface-theme="beenvoice"] .dashboard-content-shell {
|
|
||||||
padding-top: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-interface-theme="beenvoice"] .platform-header-content {
|
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-interface-theme="beenvoice"] [data-slot="card"] {
|
[data-slot="card"],
|
||||||
|
[data-slot="dialog-content"],
|
||||||
|
[data-slot="alert-dialog-content"],
|
||||||
|
[data-slot="popover-content"] {
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-interface-theme="beenvoice"] [data-slot="card-header"] {
|
[data-slot="card-header"] {
|
||||||
padding: 1rem 1rem 0.75rem;
|
padding: 1rem 1rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-interface-theme="beenvoice"] [data-slot="card-content"] {
|
[data-slot="card-content"] {
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-interface-theme="beenvoice"] [data-slot="card-footer"] {
|
[data-slot="card-footer"] {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-interface-theme="editorial"] .brand-background {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-blob {
|
.animate-blob {
|
||||||
animation: blob 7s infinite;
|
animation: blob 7s infinite;
|
||||||
}
|
}
|
||||||
@@ -728,25 +192,6 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1);
|
box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-radius] .rounded-sm {
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-radius] .rounded,
|
|
||||||
:root[data-radius] .rounded-md {
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-radius] .rounded-lg {
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-radius] .rounded-xl,
|
|
||||||
:root[data-radius] .rounded-2xl,
|
|
||||||
:root[data-radius] .rounded-3xl {
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blob {
|
@keyframes blob {
|
||||||
|
|||||||
+2
-3
@@ -8,6 +8,7 @@ import { useState } from "react";
|
|||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
import { createQueryClient } from "./query-client";
|
import { createQueryClient } from "./query-client";
|
||||||
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
import type { AppRouter } from "~/server/api/root";
|
import type { AppRouter } from "~/server/api/root";
|
||||||
|
|
||||||
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
||||||
@@ -72,7 +73,5 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getBaseUrl() {
|
function getBaseUrl() {
|
||||||
if (typeof window !== "undefined") return window.location.origin;
|
return getAppUrl();
|
||||||
if (process.env.NEXT_PUBLIC_APP_URL) return process.env.NEXT_PUBLIC_APP_URL;
|
|
||||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user