Add 'apps/web/' from commit '1e7174fa604b11e7c3983cd8ad01c596f6e77e96'
git-subtree-dir: apps/web git-subtree-mainline:068a51b46bgit-subtree-split:1e7174fa60
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||
import { brand } from "~/lib/branding";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: `%s | ${brand.name}`,
|
||||
default: `Legal | ${brand.name}`,
|
||||
},
|
||||
};
|
||||
|
||||
export default function LegalLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <MarketingProviders>{children}</MarketingProviders>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { PrivacyPolicyContent } from "~/components/legal/privacy-policy-content";
|
||||
import { LegalPageShell } from "~/components/legal/legal-page-shell";
|
||||
import { brand } from "~/lib/branding";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Privacy Policy | ${brand.name}`,
|
||||
description: `How ${brand.name} collects, uses, and protects your data.`,
|
||||
};
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
return (
|
||||
<LegalPageShell
|
||||
title="Privacy Policy"
|
||||
description={`How ${brand.name} collects, uses, and protects your data across the web and mobile apps.`}
|
||||
>
|
||||
<PrivacyPolicyContent />
|
||||
</LegalPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { LegalPageShell } from "~/components/legal/legal-page-shell";
|
||||
import { TermsOfServiceContent } from "~/components/legal/terms-of-service-content";
|
||||
import { brand } from "~/lib/branding";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Terms of Service | ${brand.name}`,
|
||||
description: `Terms governing your use of the ${brand.name} platform.`,
|
||||
};
|
||||
|
||||
export default function TermsOfServicePage() {
|
||||
return (
|
||||
<LegalPageShell
|
||||
title="Terms of Service"
|
||||
description={`The rules for using ${brand.name} on the web and mobile apps.`}
|
||||
>
|
||||
<TermsOfServiceContent />
|
||||
</LegalPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||
|
||||
export default function MarketingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <MarketingProviders>{children}</MarketingProviders>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { LandingPage } from "~/components/marketing/landing-page";
|
||||
import { env } from "~/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function HomePage() {
|
||||
const allowRegistration = env.DISABLE_SIGNUPS !== true;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<LandingPage allowRegistration={allowRegistration} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { auth } from "~/lib/auth";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({
|
||||
authentik: env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true,
|
||||
signupsDisabled: env.DISABLE_SIGNUPS === true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
import { sendPasswordResetForUser } from "~/lib/password-reset";
|
||||
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email } = (await request.json()) as { email: string };
|
||||
|
||||
if (!email || typeof email !== "string") {
|
||||
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:forgot"), {
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 10,
|
||||
});
|
||||
if (ipRateLimit) return ipRateLimit;
|
||||
|
||||
const emailRateLimit = requireRateLimit(
|
||||
rateLimitKey(request, "auth:forgot-email", normalizedEmail),
|
||||
{
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 3,
|
||||
},
|
||||
);
|
||||
if (emailRateLimit) return emailRateLimit;
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(normalizedEmail)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.email, normalizedEmail),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message:
|
||||
"If an account with that email exists, password reset instructions have been sent.",
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
await sendPasswordResetForUser(user.id);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message:
|
||||
"If an account with that email exists, password reset instructions have been sent.",
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Password reset error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "An error occurred while processing your request" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "~/lib/auth";
|
||||
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
|
||||
import { resolveNewUserRole } from "~/lib/first-admin";
|
||||
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
|
||||
import { env } from "~/env";
|
||||
import { db } from "~/server/db";
|
||||
import { accounts, users } from "~/server/db/schema";
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
firstName: z.string().trim().min(1, "First name is required"),
|
||||
lastName: z.string().trim().min(1, "Last name is required"),
|
||||
name: z.string().trim().optional(),
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
})
|
||||
.transform((data) => {
|
||||
if (data.name?.length) {
|
||||
const parts = data.name.trim().split(/\s+/);
|
||||
const firstName = parts[0] ?? "";
|
||||
const lastName = parts.slice(1).join(" ") || firstName;
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
};
|
||||
});
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
firstName: "First name",
|
||||
lastName: "Last name",
|
||||
name: "Name",
|
||||
email: "Email address",
|
||||
password: "Password",
|
||||
};
|
||||
|
||||
function formatRegisterError(error: z.ZodError): string {
|
||||
const issue = error.issues[0] ?? error.errors[0];
|
||||
if (!issue) return "Please check the registration form";
|
||||
|
||||
const field = issue.path[0];
|
||||
const label =
|
||||
typeof field === "string" ? (fieldLabels[field] ?? field) : "Field";
|
||||
|
||||
if (
|
||||
issue.code === "invalid_type" &&
|
||||
"received" in issue &&
|
||||
issue.received === "undefined"
|
||||
) {
|
||||
return `${label} is required`;
|
||||
}
|
||||
|
||||
if (issue.message && issue.message !== "Required") {
|
||||
return issue.message;
|
||||
}
|
||||
|
||||
return `${label} is required`;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const rateLimit = requireRateLimit(rateLimitKey(request, "auth:register"), {
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 5,
|
||||
});
|
||||
if (rateLimit) return rateLimit;
|
||||
|
||||
if (env.DISABLE_SIGNUPS === true) {
|
||||
return NextResponse.json(
|
||||
{ error: "New account registration is currently disabled" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request body. Please try again." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json(
|
||||
{ error: "Registration details are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = registerSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: formatRegisterError(parsed.error) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { firstName, lastName, email, password } = parsed.data;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
const emailRateLimit = requireRateLimit(
|
||||
rateLimitKey(request, "auth:register-email", normalizedEmail),
|
||||
{
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 3,
|
||||
},
|
||||
);
|
||||
if (emailRateLimit) return emailRateLimit;
|
||||
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.email, normalizedEmail),
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "Registration failed. Please check the form or sign in." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const role = await resolveNewUserRole(tx);
|
||||
|
||||
const [user] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
name: `${firstName} ${lastName}`,
|
||||
email: normalizedEmail,
|
||||
password: hashedPassword,
|
||||
role,
|
||||
})
|
||||
.returning({ id: users.id });
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Failed to create user");
|
||||
}
|
||||
|
||||
await tx.insert(accounts).values({
|
||||
userId: user.id,
|
||||
accountId: user.id,
|
||||
providerId: "credential",
|
||||
password: hashedPassword,
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await auth.api.signInEmail({
|
||||
body: {
|
||||
email: normalizedEmail,
|
||||
password,
|
||||
},
|
||||
headers: request.headers,
|
||||
});
|
||||
} catch (signInError) {
|
||||
console.error("Post-register sign-in failed:", signInError);
|
||||
return NextResponse.json(
|
||||
{ message: "User created successfully", signInRequired: true },
|
||||
{ status: 201 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "User created successfully" },
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
|
||||
const databaseSetupError = getDatabaseSetupErrorMessage(error);
|
||||
if (databaseSetupError) {
|
||||
return NextResponse.json({ error: databaseSetupError }, { status: 503 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { hashPasswordResetToken } from "~/lib/reset-token";
|
||||
import { revokeUserSessions } from "~/lib/session-security";
|
||||
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
|
||||
import { db } from "~/server/db";
|
||||
import { accounts, users } from "~/server/db/schema";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:reset"), {
|
||||
windowMs: 60 * 1000,
|
||||
max: 10,
|
||||
});
|
||||
if (ipRateLimit) return ipRateLimit;
|
||||
|
||||
const { token, password } = (await request.json()) as {
|
||||
token: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
if (!token || typeof token !== "string") {
|
||||
return NextResponse.json({ error: "Token is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!password || typeof password !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "Password is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password must be at least 8 characters long" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const tokenRateLimit = requireRateLimit(
|
||||
rateLimitKey(request, "auth:reset-token", token),
|
||||
{
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 5,
|
||||
},
|
||||
);
|
||||
if (tokenRateLimit) return tokenRateLimit;
|
||||
|
||||
const tokenHash = hashPasswordResetToken(token);
|
||||
|
||||
// Find user with valid reset token that hasn't expired
|
||||
const user = await db.query.users.findFirst({
|
||||
where: and(
|
||||
eq(users.resetToken, tokenHash),
|
||||
gt(users.resetTokenExpiry, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or expired token" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
const credentialAccount = await tx.query.accounts.findFirst({
|
||||
where: and(
|
||||
eq(accounts.userId, user.id),
|
||||
eq(accounts.providerId, "credential"),
|
||||
),
|
||||
});
|
||||
|
||||
if (credentialAccount) {
|
||||
await tx
|
||||
.update(accounts)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(accounts.id, credentialAccount.id));
|
||||
} else {
|
||||
await tx.insert(accounts).values({
|
||||
userId: user.id,
|
||||
accountId: user.id,
|
||||
providerId: "credential",
|
||||
password: hashedPassword,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await revokeUserSessions(user.id);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: "Password has been reset successfully",
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Password reset error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "An error occurred while resetting your password" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
import { hashPasswordResetToken } from "~/lib/reset-token";
|
||||
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:validate-reset"), {
|
||||
windowMs: 60 * 1000,
|
||||
max: 20,
|
||||
});
|
||||
if (ipRateLimit) return ipRateLimit;
|
||||
|
||||
const { token } = (await request.json()) as { token: string };
|
||||
|
||||
if (!token || typeof token !== "string") {
|
||||
return NextResponse.json({ error: "Token is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const tokenRateLimit = requireRateLimit(
|
||||
rateLimitKey(request, "auth:validate-reset-token", token),
|
||||
{
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 5,
|
||||
},
|
||||
);
|
||||
if (tokenRateLimit) return tokenRateLimit;
|
||||
|
||||
const tokenHash = hashPasswordResetToken(token);
|
||||
|
||||
// Find user with valid reset token that hasn't expired
|
||||
const user = await db.query.users.findFirst({
|
||||
where: and(
|
||||
eq(users.resetToken, tokenHash),
|
||||
gt(users.resetTokenExpiry, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or expired token" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ valid: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Token validation error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "An error occurred while validating the token" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getObject } from "~/lib/object-storage";
|
||||
import { db } from "~/server/db";
|
||||
import { businesses } from "~/server/db/schema";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const RASTERIZABLE_MIME_TYPES = new Set(["image/svg+xml", "image/webp"]);
|
||||
|
||||
// Intentionally unauthenticated: a business logo must be viewable on public,
|
||||
// token-based invoice pages without a session. Business IDs are random
|
||||
// UUIDs, so this only serves images to callers who already know the ID.
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ businessId: string }> },
|
||||
) {
|
||||
const { businessId } = await params;
|
||||
const business = await db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, businessId),
|
||||
columns: { logoStorageKey: true, logoMimeType: true },
|
||||
});
|
||||
|
||||
if (!business?.logoStorageKey || !business.logoMimeType) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF
|
||||
// generation requests a rasterized copy of SVG/WebP logos via this param.
|
||||
const wantsPng =
|
||||
new URL(req.url).searchParams.get("format") === "png" &&
|
||||
RASTERIZABLE_MIME_TYPES.has(business.logoMimeType);
|
||||
|
||||
try {
|
||||
const body = await getObject(business.logoStorageKey);
|
||||
|
||||
if (wantsPng) {
|
||||
const { default: sharp } = await import("sharp");
|
||||
const isSvg = business.logoMimeType === "image/svg+xml";
|
||||
// SVG is vector: rasterize at a high density so the PNG stays crisp at
|
||||
// the size it's actually displayed (PDF header, up to ~2.2in wide).
|
||||
// withoutEnlargement only makes sense for the WebP (already-raster)
|
||||
// case — for SVG it would cap us at whatever tiny canvas the source's
|
||||
// intrinsic viewBox implies, even though the vector has no such limit.
|
||||
const png = await sharp(body, isSvg ? { density: 600 } : undefined)
|
||||
.resize({
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
fit: "inside",
|
||||
withoutEnlargement: !isSvg,
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
return new NextResponse(new Uint8Array(png), {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=300, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse(new Uint8Array(body), {
|
||||
headers: {
|
||||
"Content-Type": business.logoMimeType,
|
||||
"Cache-Control": "public, max-age=300, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[business-logo] Failed to serve logo", {
|
||||
backendError: error,
|
||||
businessId,
|
||||
wantsPng,
|
||||
});
|
||||
return NextResponse.json({ error: "Logo not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { env } from "~/env";
|
||||
import { db } from "~/server/db";
|
||||
import { generateDueRecurringInvoices } from "~/server/api/routers/recurring-invoices";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
const secret = env.CRON_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cron secret is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
if (authHeader !== `Bearer ${secret}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const generated = await generateDueRecurringInvoices(db);
|
||||
return NextResponse.json({ generated });
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { invoices, platformSettings } from "~/server/db/schema";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
|
||||
const invoice = await db.query.invoices.findFirst({
|
||||
where: eq(invoices.publicToken, token),
|
||||
with: {
|
||||
client: true,
|
||||
// Explicit allowlist: token-based public route — never fetch
|
||||
// secret fields (resendApiKey, resendDomain) for an unauthenticated request.
|
||||
business: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
nickname: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
addressLine1: true,
|
||||
addressLine2: true,
|
||||
city: true,
|
||||
state: true,
|
||||
postalCode: true,
|
||||
country: true,
|
||||
website: true,
|
||||
taxId: true,
|
||||
logoStorageKey: true,
|
||||
logoMimeType: true,
|
||||
hideNameWithLogo: true,
|
||||
},
|
||||
},
|
||||
items: {
|
||||
orderBy: (i, { asc }) => [
|
||||
asc(i.date),
|
||||
asc(i.position),
|
||||
asc(i.createdAt),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) {
|
||||
return NextResponse.json({ error: "This link has expired" }, { status: 410 });
|
||||
}
|
||||
|
||||
const settings = await db.query.platformSettings.findFirst({
|
||||
where: eq(platformSettings.id, "global"),
|
||||
});
|
||||
|
||||
const pdfBlob = await generateInvoicePDFBlob(
|
||||
invoice,
|
||||
{
|
||||
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
},
|
||||
{ logoBaseUrl: new URL(request.url).origin },
|
||||
);
|
||||
|
||||
const buffer = await pdfBlob.arrayBuffer();
|
||||
const filename = `invoice-${invoice.invoiceNumber}.pdf`;
|
||||
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${filename}"`,
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getOptionalServerSession } from "~/lib/auth-server";
|
||||
import { getObject } from "~/lib/object-storage";
|
||||
import { db } from "~/server/db";
|
||||
import { expenseReceipts } from "~/server/db/schema";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await getOptionalServerSession(req.headers);
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const receipt = await db.query.expenseReceipts.findFirst({
|
||||
where: eq(expenseReceipts.id, id),
|
||||
with: { expense: true },
|
||||
});
|
||||
|
||||
if (receipt?.expense.createdById !== session.user.id) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await getObject(receipt.storageKey);
|
||||
return new NextResponse(new Uint8Array(body), {
|
||||
headers: {
|
||||
"Content-Type": receipt.mimeType,
|
||||
"Content-Disposition": `inline; filename="${encodeURIComponent(receipt.originalFilename)}"`,
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { appRouter } from "~/server/api/root";
|
||||
import { createTRPCContext } from "~/server/api/trpc";
|
||||
|
||||
/**
|
||||
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||
* handling a HTTP request (e.g. when you make requests from Client Components).
|
||||
*/
|
||||
const createContext = async (req: NextRequest) => {
|
||||
return createTRPCContext({
|
||||
headers: req.headers,
|
||||
});
|
||||
};
|
||||
|
||||
const handler = (req: NextRequest) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => createContext(req),
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -0,0 +1,368 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Suspense } from "react";
|
||||
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,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Shield,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
function ForgotPasswordForm() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/forgot-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as { error?: string };
|
||||
|
||||
if (response.ok) {
|
||||
setSent(true);
|
||||
toast.success("Password reset instructions sent to your email");
|
||||
} else {
|
||||
toast.error(data.error ?? "Failed to send reset email");
|
||||
}
|
||||
} catch {
|
||||
toast.error("An error occurred. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||
{/* Hero Section - Hidden on mobile */}
|
||||
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<Logo size="xl" />
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||
Check your
|
||||
<span className="text-primary"> email inbox</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
We've sent password reset instructions to your email
|
||||
address. Follow the link to create a new password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Mail className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold">Check your inbox</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Look for an email from beenvoice with reset instructions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Clock className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold">Link expires soon</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The reset link is valid for 24 hours only
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold">Secure Process</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your account security is our top priority
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-primary/5 flex items-center space-x-4 rounded-lg p-4">
|
||||
<CheckCircle className="text-primary h-8 w-8" />
|
||||
<div>
|
||||
<p className="font-semibold">Email sent successfully</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Follow the instructions in your email to reset your
|
||||
password
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||
{/* Mobile Logo */}
|
||||
<div className="flex justify-center md:hidden">
|
||||
<Logo size="lg" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<div className="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<CheckCircle className="text-primary h-8 w-8" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Check your email</h1>
|
||||
<p className="text-muted-foreground">
|
||||
We've sent password reset instructions to{" "}
|
||||
<span className="font-medium">{email}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 space-y-3 rounded-lg p-4">
|
||||
<h3 className="font-semibold">What's next?</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-start space-x-2">
|
||||
<span className="text-primary">1.</span>
|
||||
<span>Check your email inbox (and spam folder)</span>
|
||||
</li>
|
||||
<li className="flex items-start space-x-2">
|
||||
<span className="text-primary">2.</span>
|
||||
<span>Click the reset link in the email</span>
|
||||
</li>
|
||||
<li className="flex items-start space-x-2">
|
||||
<span className="text-primary">3.</span>
|
||||
<span>Create a new secure password</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSent(false);
|
||||
setEmail("");
|
||||
}}
|
||||
variant="outline"
|
||||
className="h-11 w-full"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Try a different email
|
||||
</Button>
|
||||
|
||||
<a href="/auth/signin">
|
||||
<Button className="h-11 w-full">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Sign In
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-center text-xs">
|
||||
Didn't receive the email? Check your spam folder or{" "}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSent(false);
|
||||
toast.info("You can try sending the email again");
|
||||
}}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
try again
|
||||
</button>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||
{/* Hero Section - Hidden on mobile */}
|
||||
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<Logo size="xl" />
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||
Forgot your
|
||||
<span className="text-primary"> password?</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
No worries! Enter your email address and we'll send you
|
||||
instructions to reset your password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Mail className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold">Email Instructions</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
We'll send a secure link to your email address
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Clock className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold">Quick Process</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Reset your password in just a few clicks
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold">Secure & Safe</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your account security is our top priority
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Forgot Password Form */}
|
||||
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||
{/* Mobile Logo */}
|
||||
<div className="flex justify-center md:hidden">
|
||||
<Logo size="lg" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center md:text-left">
|
||||
<h1 className="text-2xl font-bold">Forgot Password</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Enter your email and we'll send you reset instructions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</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"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="h-11 pl-10"
|
||||
placeholder="Enter your email address"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-11 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"></div>
|
||||
<span>Sending instructions...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Send Reset Instructions</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Mail className="text-primary mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Check your spam folder</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Sometimes our emails end up in spam or promotions folders
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="/auth/signin"
|
||||
className="text-primary inline-flex items-center space-x-1 text-sm font-medium hover:underline"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
<span>Back to Sign In</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-center text-xs">
|
||||
Remember your password?{" "}
|
||||
<a
|
||||
href="/auth/signin"
|
||||
className="text-primary font-medium hover:underline"
|
||||
>
|
||||
Sign in instead
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<LegalAgreementNotice
|
||||
action="using our service"
|
||||
className="leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ForgotPasswordForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <MarketingProviders>{children}</MarketingProviders>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { env } from "~/env";
|
||||
import { RegisterForm } from "./register-form";
|
||||
|
||||
export default function RegisterPage() {
|
||||
return <RegisterForm signupsDisabled={env.DISABLE_SIGNUPS === true} />;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Lock, Mail, User, UserX } 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;
|
||||
}
|
||||
|
||||
interface RegisterFormProps {
|
||||
signupsDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function RegisterForm({ signupsDisabled = false }: RegisterFormProps) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (signupsDisabled) {
|
||||
return (
|
||||
<AuthPageShell>
|
||||
<AuthCard>
|
||||
<AuthCardHeader
|
||||
title="Registration closed"
|
||||
description="New account sign-ups are not available right now"
|
||||
/>
|
||||
|
||||
<div className="bg-muted/50 text-muted-foreground mb-6 flex gap-3 rounded-xl border px-4 py-3 text-sm">
|
||||
<UserX className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
This workspace is not accepting new registrations. If you already
|
||||
have an account, sign in below. Contact your administrator if you
|
||||
need access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button asChild className="h-11 w-full">
|
||||
<Link href="/auth/signin">
|
||||
Sign in to your account
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</AuthCard>
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Suspense, useEffect } from "react";
|
||||
import { useSearchParams } 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 {
|
||||
Lock,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Shield,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
|
||||
function ResetPasswordForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [tokenValid, setTokenValid] = useState<boolean | null>(() =>
|
||||
token ? null : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate token on page load
|
||||
const validateToken = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/validate-reset-token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTokenValid(true);
|
||||
} else {
|
||||
setTokenValid(false);
|
||||
}
|
||||
} catch {
|
||||
setTokenValid(false);
|
||||
}
|
||||
};
|
||||
|
||||
void validateToken();
|
||||
}, [token]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!token) {
|
||||
toast.error("Invalid reset token");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
toast.error("Password must be at least 8 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/reset-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as { error?: string };
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess(true);
|
||||
toast.success("Password reset successfully!");
|
||||
} else {
|
||||
toast.error(data.error ?? "Failed to reset password");
|
||||
}
|
||||
} catch {
|
||||
toast.error("An error occurred. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenValid === null) {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"></div>
|
||||
<p className="text-muted-foreground mt-4">
|
||||
Validating reset token...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tokenValid === false) {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||
{/* Hero Section - Hidden on mobile */}
|
||||
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<Logo size="xl" />
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||
Invalid or
|
||||
<span className="text-destructive"> expired link</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
This password reset link is either invalid or has expired.
|
||||
Please request a new password reset.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="bg-destructive/10 rounded-lg p-2">
|
||||
<Shield className="text-destructive h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold">Security First</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Reset links expire after 24 hours for your security
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Form */}
|
||||
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||
{/* Mobile Logo */}
|
||||
<div className="flex justify-center md:hidden">
|
||||
<Logo size="lg" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<div className="bg-destructive/10 justify-content mx-auto mb-4 flex h-16 w-16 items-center rounded-full">
|
||||
<Shield className="text-destructive mx-auto h-8 w-8" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Link Expired</h1>
|
||||
<p className="text-muted-foreground">
|
||||
This password reset link is no longer valid
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<a href="/auth/forgot-password">
|
||||
<Button className="h-11 w-full">
|
||||
Request New Reset Link
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<a href="/auth/signin">
|
||||
<Button variant="outline" className="h-11 w-full">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Sign In
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||
{/* Hero Section - Hidden on mobile */}
|
||||
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<Logo size="xl" />
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||
Password
|
||||
<span className="text-primary"> reset complete</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Your password has been successfully reset. You can now
|
||||
sign in with your new password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-primary/5 rounded-lg p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<CheckCircle className="text-primary h-6 w-6" />
|
||||
<div>
|
||||
<p className="font-semibold">Security Updated</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your account is now secured with your new password
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Form */}
|
||||
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||
{/* Mobile Logo */}
|
||||
<div className="flex justify-center md:hidden">
|
||||
<Logo size="lg" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<div className="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<CheckCircle className="text-primary h-8 w-8" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
Password Reset Complete
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Your password has been successfully updated
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<a href="/auth/signin">
|
||||
<Button className="h-11 w-full">
|
||||
<ArrowRight className="mr-2 h-4 w-4" />
|
||||
Sign In Now
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||
{/* Hero Section - Hidden on mobile */}
|
||||
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<Logo size="xl" />
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||
Create your
|
||||
<span className="text-primary"> new password</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Choose a strong password to secure your beenvoice account.
|
||||
Make sure it's something you'll remember.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold">Secure Password</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Use at least 8 characters with a mix of letters and
|
||||
numbers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Lock className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold">Account Safety</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your new password will immediately secure your account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset Password Form */}
|
||||
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||
{/* Mobile Logo */}
|
||||
<div className="flex justify-center md:hidden">
|
||||
<Logo size="lg" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center md:text-left">
|
||||
<h1 className="text-2xl font-bold">Reset Password</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Enter your new password below
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">New 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"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="h-11 pr-10 pl-10"
|
||||
placeholder="Enter new password"
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 z-10 -translate-y-1/2"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Must be at least 8 characters long
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm 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="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="h-11 pr-10 pl-10"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 z-10 -translate-y-1/2"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-11 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"></div>
|
||||
<span>Updating password...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Update Password</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="/auth/signin"
|
||||
className="text-primary inline-flex items-center space-x-1 text-sm font-medium hover:underline"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
<span>Back to Sign In</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<LegalAgreementNotice
|
||||
action="resetting your password"
|
||||
className="leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Suspense } from "react";
|
||||
import { env } from "~/env";
|
||||
import { SignInForm } from "./signin-form";
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="bg-dashboard text-muted-foreground flex min-h-screen items-center justify-center text-sm">
|
||||
Loading…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SignInForm allowRegistration={env.DISABLE_SIGNUPS !== true} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ArrowRight, Lock, Mail, Shield } 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 { env } from "~/env";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import { safeCallbackPath } from "~/lib/safe-callback-url";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface SignInFormProps {
|
||||
allowRegistration: boolean;
|
||||
}
|
||||
|
||||
export function SignInForm({ allowRegistration }: SignInFormProps) {
|
||||
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = safeCallbackPath(searchParams.get("callbackUrl"));
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSignIn(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
const { error } = await authClient.signIn.email({ email, password });
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (error) {
|
||||
const message = error.message?.toLowerCase() ?? "";
|
||||
const rateLimited =
|
||||
error.status === 429 ||
|
||||
message.includes("too many") ||
|
||||
message.includes("rate limit");
|
||||
toast.error(
|
||||
rateLimited
|
||||
? "Too many sign-in attempts. Please wait a moment and try again."
|
||||
: error.message && error.message !== "Required"
|
||||
? error.message
|
||||
: "Invalid email or password",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Signed in successfully!");
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleSocialSignIn() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await authClient.signIn.oauth2({
|
||||
providerId: "authentik",
|
||||
callbackURL: callbackUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[SSO Error]", error);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPageShell>
|
||||
<AuthCard>
|
||||
<AuthCardHeader
|
||||
title="Welcome back"
|
||||
description="Sign in to your workspace"
|
||||
/>
|
||||
|
||||
{!allowRegistration && (
|
||||
<p className="bg-muted/50 text-muted-foreground mb-5 rounded-xl border px-3 py-2.5 text-sm">
|
||||
New account registration is currently disabled.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{authentikEnabled && (
|
||||
<div className="mb-5 space-y-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="h-11 w-full"
|
||||
onClick={handleSocialSignIn}
|
||||
disabled={loading}
|
||||
>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
Sign in with Authentik
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="border-border/50 w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background/80 text-muted-foreground px-2">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSignIn} className="space-y-4">
|
||||
<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"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="email"
|
||||
className="h-11 pl-10"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
className="text-muted-foreground text-xs hover:text-foreground hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<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"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="h-11 pl-10"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="h-11 w-full" disabled={loading}>
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
{!loading && <ArrowRight className="ml-2 h-4 w-4" />}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{allowRegistration && (
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="text-foreground font-medium hover:underline"
|
||||
>
|
||||
Create account
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<LegalAgreementNotice action="signing in" className="mt-5" />
|
||||
</AuthCard>
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Square, Clock } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
describeClockOutOutcome,
|
||||
formatElapsedSeconds,
|
||||
formatRunningTimerLabel,
|
||||
} from "~/lib/time-clock";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface ActiveTimerWidgetProps {
|
||||
collapsed?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ActiveTimerWidget({
|
||||
collapsed = false,
|
||||
compact = false,
|
||||
}: ActiveTimerWidgetProps) {
|
||||
const utils = api.useUtils();
|
||||
const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(
|
||||
undefined,
|
||||
{
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: 60_000,
|
||||
},
|
||||
);
|
||||
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (running) {
|
||||
const tick = () =>
|
||||
setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000));
|
||||
tick();
|
||||
intervalRef.current = setInterval(tick, 1000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [running]);
|
||||
|
||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||
onSuccess: (data) => {
|
||||
const message = describeClockOutOutcome({
|
||||
outcome: data.outcome,
|
||||
hours: data.hours,
|
||||
rate: data.rate,
|
||||
invoice: data.invoice,
|
||||
});
|
||||
|
||||
if (data.outcome === "linked_to_invoice" && data.invoice) {
|
||||
toast.success("Timer stopped", {
|
||||
description: message,
|
||||
action: {
|
||||
label: "View invoice",
|
||||
onClick: () =>
|
||||
window.location.assign(`/dashboard/invoices/${data.invoice!.id}`),
|
||||
},
|
||||
});
|
||||
} else if (data.outcome === "saved_no_invoice" || data.outcome === "saved_no_client") {
|
||||
toast.warning("Time saved", { description: message });
|
||||
} else {
|
||||
toast.success(message);
|
||||
}
|
||||
|
||||
void utils.timeEntries.getRunning.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
if (isLoading || !running) return null;
|
||||
|
||||
const invoiceLabel = running.invoice
|
||||
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||
: null;
|
||||
|
||||
const description = formatRunningTimerLabel(running.description);
|
||||
|
||||
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">
|
||||
{description}
|
||||
{running.client && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" "}
|
||||
· {running.client.name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-primary font-mono text-lg font-bold tabular-nums">
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</p>
|
||||
{invoiceLabel ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Billing to{" "}
|
||||
<Link
|
||||
href={`/dashboard/invoices/${running.invoice!.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{invoiceLabel}
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">No invoice selected</p>
|
||||
)}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button variant="outline" size="sm" asChild className="h-8 flex-1">
|
||||
<Link href="/dashboard/time-clock">Open</Link>
|
||||
</Button>
|
||||
{renderStopButton()}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardContent className="flex flex-col gap-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="relative mt-1 flex h-2.5 w-2.5 flex-shrink-0">
|
||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm leading-snug font-medium">
|
||||
{description}
|
||||
{running.client && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" "}
|
||||
· {running.client.name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-snug">
|
||||
{invoiceLabel ? (
|
||||
<>
|
||||
Billing to{" "}
|
||||
<Link
|
||||
href={`/dashboard/invoices/${running.invoice!.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{invoiceLabel}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>No invoice selected — open time clock to assign</>
|
||||
)}
|
||||
{" · "}
|
||||
<Link href="/dashboard/time-clock" className="text-primary hover:underline">
|
||||
Time clock
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-primary text-center font-mono text-xl font-bold tabular-nums">
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</span>
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Button variant="outline" size="sm" asChild className="h-8 w-full">
|
||||
<Link href="/dashboard/time-clock">
|
||||
<Clock className="mr-1 h-3.5 w-3.5" />
|
||||
Open
|
||||
</Link>
|
||||
</Button>
|
||||
{renderStopButton("w-full")}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Minus,
|
||||
DollarSign,
|
||||
Clock,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type IconName = "DollarSign" | "Clock" | "Users" | "TrendingDown";
|
||||
|
||||
interface AnimatedStatsCardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
change: string;
|
||||
trend: "up" | "down" | "neutral";
|
||||
iconName: IconName;
|
||||
description: string;
|
||||
delay?: number;
|
||||
isCurrency?: boolean;
|
||||
numericValue?: number;
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
DollarSign,
|
||||
Clock,
|
||||
Users,
|
||||
TrendingDown,
|
||||
} as const;
|
||||
|
||||
export function AnimatedStatsCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
trend,
|
||||
iconName,
|
||||
description,
|
||||
delay = 0,
|
||||
isCurrency = false,
|
||||
numericValue,
|
||||
}: AnimatedStatsCardProps) {
|
||||
const Icon = iconMap[iconName];
|
||||
|
||||
let TrendIcon = Minus;
|
||||
if (trend === "up") TrendIcon = TrendingUp;
|
||||
if (trend === "down") TrendIcon = TrendingDown;
|
||||
|
||||
const isPositive = trend === "up";
|
||||
const isNeutral = trend === "neutral";
|
||||
|
||||
void delay;
|
||||
void isCurrency;
|
||||
void numericValue;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-muted-foreground flex items-center gap-2 text-sm font-medium">
|
||||
<Icon className="h-4 w-4" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs font-medium",
|
||||
isNeutral
|
||||
? "text-muted-foreground"
|
||||
: isPositive
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-amber-600 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
<TrendIcon className="h-3 w-3" />
|
||||
<span className="font-mono tabular-nums">{change}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="font-mono text-2xl font-semibold tracking-tight tabular-nums">
|
||||
{value}
|
||||
</p>
|
||||
<CardDescription className="mt-1">{description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
const chartSkeleton = () => <Skeleton className="h-64 w-full" />;
|
||||
|
||||
export const RevenueChart = dynamic(
|
||||
() => import("./revenue-chart").then((m) => m.RevenueChart),
|
||||
{ ssr: false, loading: chartSkeleton },
|
||||
);
|
||||
|
||||
export const InvoiceStatusChart = dynamic(
|
||||
() => import("./invoice-status-chart").then((m) => m.InvoiceStatusChart),
|
||||
{ ssr: false, loading: chartSkeleton },
|
||||
);
|
||||
|
||||
export const MonthlyMetricsChart = dynamic(
|
||||
() => import("./monthly-metrics-chart").then((m) => m.MonthlyMetricsChart),
|
||||
{ ssr: false, loading: chartSkeleton },
|
||||
);
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { Cell, Pie, PieChart, Tooltip } from "recharts";
|
||||
import { ResponsiveChart } from "~/components/charts/responsive-chart";
|
||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||
|
||||
export interface StatusChartDatum {
|
||||
status: string;
|
||||
name: string;
|
||||
count: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface InvoiceStatusChartProps {
|
||||
data: StatusChartDatum[];
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
draft: "hsl(0, 0%, 60%)",
|
||||
sent: "hsl(217, 91%, 60%)",
|
||||
pending: "hsl(217, 91%, 60%)",
|
||||
paid: "hsl(142, 71%, 45%)",
|
||||
overdue: "hsl(var(--destructive))",
|
||||
} as const;
|
||||
|
||||
const formatChartCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
function StatusTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
payload: { name: string; count: number; value: number };
|
||||
}>;
|
||||
}) {
|
||||
if (active && payload?.length) {
|
||||
const data = payload[0]!.payload;
|
||||
return (
|
||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||
<p className="font-medium">{data.name}</p>
|
||||
<p className="font-mono text-sm tabular-nums">
|
||||
{data.count} invoice{data.count !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<p className="font-mono text-sm tabular-nums">
|
||||
{formatChartCurrency(data.value)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function InvoiceStatusChart({ data }: InvoiceStatusChartProps) {
|
||||
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||
useAnimationPreferences();
|
||||
const pieAnimationDuration = Math.round(
|
||||
600 / (animationSpeedMultiplier || 1),
|
||||
);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No invoice data available
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Status breakdown will appear here once you create invoices
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ResponsiveChart height={192} className="h-48">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={40}
|
||||
outerRadius={80}
|
||||
stroke="none"
|
||||
dataKey="count"
|
||||
isAnimationActive={!prefersReducedMotion}
|
||||
animationDuration={pieAnimationDuration}
|
||||
animationEasing="ease-out"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={
|
||||
STATUS_COLORS[entry.status as keyof typeof STATUS_COLORS]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<StatusTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveChart>
|
||||
|
||||
<div className="space-y-2">
|
||||
{data.map((item) => (
|
||||
<div key={item.status} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
STATUS_COLORS[item.status as keyof typeof STATUS_COLORS],
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium">{item.name}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-mono text-sm font-medium tabular-nums">
|
||||
{item.count}
|
||||
</p>
|
||||
<p className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||
{formatChartCurrency(item.value)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { ResponsiveChart } from "~/components/charts/responsive-chart";
|
||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||
|
||||
export interface MonthlyMetricsChartDatum {
|
||||
month: string;
|
||||
monthLabel: string;
|
||||
totalInvoices: number;
|
||||
paidInvoices: number;
|
||||
pendingInvoices: number;
|
||||
overdueInvoices: number;
|
||||
draftInvoices: number;
|
||||
}
|
||||
|
||||
interface MonthlyMetricsChartProps {
|
||||
data: MonthlyMetricsChartDatum[];
|
||||
}
|
||||
|
||||
function MonthlyMetricsTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
payload: MonthlyMetricsChartDatum;
|
||||
}>;
|
||||
label?: string;
|
||||
}) {
|
||||
if (active && payload?.length) {
|
||||
const chartDatum = payload[0]!.payload;
|
||||
return (
|
||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||
<p className="font-medium">{label}</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="text-primary font-medium font-mono tabular-nums">
|
||||
Paid: {chartDatum.paidInvoices}
|
||||
</p>
|
||||
<p className="text-primary/80 font-mono tabular-nums">
|
||||
Pending: {chartDatum.pendingInvoices}
|
||||
</p>
|
||||
<p className="text-destructive font-mono tabular-nums">
|
||||
Overdue: {chartDatum.overdueInvoices}
|
||||
</p>
|
||||
<p className="text-muted-foreground font-mono tabular-nums">
|
||||
Draft: {chartDatum.draftInvoices}
|
||||
</p>
|
||||
<p className="text-foreground border-t pt-1 font-medium font-mono tabular-nums">
|
||||
Total: {chartDatum.totalInvoices}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MonthlyMetricsChart({ data }: MonthlyMetricsChartProps) {
|
||||
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||
useAnimationPreferences();
|
||||
const barAnimationDuration = Math.round(
|
||||
500 / (animationSpeedMultiplier || 1),
|
||||
);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No metrics data available
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Monthly metrics will appear here once you create invoices
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ResponsiveChart height={192} className="h-48">
|
||||
<BarChart data={data}>
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{
|
||||
fontSize: 12,
|
||||
fill: "var(--muted-foreground)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={<MonthlyMetricsTooltip />} />
|
||||
<Bar
|
||||
dataKey="draftInvoices"
|
||||
stackId="a"
|
||||
fill="hsl(0, 0%, 60%)"
|
||||
radius={[0, 0, 0, 0]}
|
||||
isAnimationActive={!prefersReducedMotion}
|
||||
animationDuration={barAnimationDuration}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="paidInvoices"
|
||||
stackId="a"
|
||||
fill="hsl(142, 71%, 45%)"
|
||||
radius={[0, 0, 0, 0]}
|
||||
isAnimationActive={!prefersReducedMotion}
|
||||
animationDuration={barAnimationDuration}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="pendingInvoices"
|
||||
stackId="a"
|
||||
fill="hsl(217, 91%, 60%)"
|
||||
fillOpacity={0.6}
|
||||
radius={[0, 0, 0, 0]}
|
||||
isAnimationActive={!prefersReducedMotion}
|
||||
animationDuration={barAnimationDuration}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="overdueInvoices"
|
||||
stackId="a"
|
||||
fill="hsl(var(--destructive))"
|
||||
radius={[2, 2, 0, 0]}
|
||||
isAnimationActive={!prefersReducedMotion}
|
||||
animationDuration={barAnimationDuration}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveChart>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: "hsl(0, 0%, 60%)" }}
|
||||
/>
|
||||
<span className="text-xs">Draft</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: "hsl(142, 71%, 45%)" }}
|
||||
/>
|
||||
<span className="text-xs">Paid</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: "hsl(217, 91%, 60%)", opacity: 0.6 }}
|
||||
/>
|
||||
<span className="text-xs">Pending</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="bg-destructive h-3 w-3 rounded-full" />
|
||||
<span className="text-xs">Overdue</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { ResponsiveChart } from "~/components/charts/responsive-chart";
|
||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||
|
||||
interface RevenueChartProps {
|
||||
data: {
|
||||
month: string;
|
||||
revenue: number;
|
||||
monthLabel: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: { revenue: number } }>;
|
||||
label?: string;
|
||||
}) => {
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
if (active && payload?.length) {
|
||||
const data = payload[0]!.payload;
|
||||
return (
|
||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||
<p className="font-medium">{label}</p>
|
||||
<p
|
||||
className="font-mono tabular-nums"
|
||||
style={{ color: "hsl(0, 0%, 60%)" }}
|
||||
>
|
||||
Revenue: {formatCurrency(data.revenue)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{/* Count not available in aggregated view currently */}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function RevenueChart({ data }: RevenueChartProps) {
|
||||
// Use data directly
|
||||
const chartData = data;
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||
useAnimationPreferences();
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No revenue data available
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Revenue will appear here once you have paid invoices
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveChart height={256} className="h-48 md:h-64">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(217, 91%, 60%)"
|
||||
stopOpacity={0.4}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(217, 91%, 60%)"
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{
|
||||
fontSize: 12,
|
||||
fill: "hsl(var(--muted-foreground))",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
tickFormatter={formatCurrency}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="hsl(217, 91%, 60%)"
|
||||
strokeWidth={2}
|
||||
fill="url(#revenueGradient)"
|
||||
isAnimationActive={!prefersReducedMotion}
|
||||
animationDuration={Math.round(
|
||||
600 / (animationSpeedMultiplier ?? 1),
|
||||
)}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveChart>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
Send,
|
||||
DollarSign,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import {
|
||||
getEffectiveInvoiceStatus,
|
||||
isInvoiceOverdue,
|
||||
getDaysPastDue,
|
||||
getStatusConfig,
|
||||
} from "~/lib/invoice-status";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
|
||||
interface StatusManagerProps {
|
||||
invoiceId: string;
|
||||
currentStatus: StoredInvoiceStatus;
|
||||
dueDate: Date;
|
||||
clientEmail?: string | null;
|
||||
onStatusChange?: () => void;
|
||||
}
|
||||
|
||||
const statusIconConfig = {
|
||||
draft: FileText,
|
||||
sent: Send,
|
||||
paid: CheckCircle,
|
||||
overdue: AlertCircle,
|
||||
};
|
||||
|
||||
export function StatusManager({
|
||||
invoiceId,
|
||||
currentStatus,
|
||||
dueDate,
|
||||
clientEmail,
|
||||
onStatusChange,
|
||||
}: StatusManagerProps) {
|
||||
const [isChangingStatus, setIsChangingStatus] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
void utils.invoices.getAll.invalidate();
|
||||
onStatusChange?.();
|
||||
setIsChangingStatus(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message ?? "Failed to update status");
|
||||
setIsChangingStatus(false);
|
||||
},
|
||||
});
|
||||
|
||||
const sendEmail = api.email.sendInvoice.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
void utils.invoices.getAll.invalidate();
|
||||
onStatusChange?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleStatusUpdate = async (newStatus: StoredInvoiceStatus) => {
|
||||
setIsChangingStatus(true);
|
||||
updateStatus.mutate({
|
||||
id: invoiceId,
|
||||
status: newStatus,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendEmail = () => {
|
||||
sendEmail.mutate({ invoiceId });
|
||||
};
|
||||
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(currentStatus, dueDate);
|
||||
const isOverdue = isInvoiceOverdue(currentStatus, dueDate);
|
||||
const daysPastDue = getDaysPastDue(currentStatus, dueDate);
|
||||
const statusConfig = getStatusConfig(currentStatus, dueDate);
|
||||
|
||||
const StatusIcon = statusIconConfig[effectiveStatus];
|
||||
|
||||
const getAvailableActions = () => {
|
||||
const actions = [];
|
||||
|
||||
switch (effectiveStatus) {
|
||||
case "draft":
|
||||
if (clientEmail) {
|
||||
actions.push({
|
||||
key: "send",
|
||||
label: "Send Invoice",
|
||||
action: handleSendEmail,
|
||||
variant: "default" as const,
|
||||
icon: Send,
|
||||
disabled: sendEmail.isPending,
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
key: "markPaid",
|
||||
label: "Mark as Paid",
|
||||
action: () => handleStatusUpdate("paid"),
|
||||
variant: "secondary" as const,
|
||||
icon: DollarSign,
|
||||
disabled: isChangingStatus,
|
||||
});
|
||||
break;
|
||||
|
||||
case "sent":
|
||||
actions.push({
|
||||
key: "markPaid",
|
||||
label: "Mark as Paid",
|
||||
action: () => handleStatusUpdate("paid"),
|
||||
variant: "default" as const,
|
||||
icon: DollarSign,
|
||||
disabled: isChangingStatus,
|
||||
});
|
||||
if (clientEmail) {
|
||||
actions.push({
|
||||
key: "resend",
|
||||
label: "Resend Invoice",
|
||||
action: handleSendEmail,
|
||||
variant: "outline" as const,
|
||||
icon: Send,
|
||||
disabled: sendEmail.isPending,
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
key: "backToDraft",
|
||||
label: "Back to Draft",
|
||||
action: () => handleStatusUpdate("draft"),
|
||||
variant: "outline" as const,
|
||||
icon: FileText,
|
||||
disabled: isChangingStatus,
|
||||
});
|
||||
break;
|
||||
|
||||
case "overdue":
|
||||
actions.push({
|
||||
key: "markPaid",
|
||||
label: "Mark as Paid",
|
||||
action: () => handleStatusUpdate("paid"),
|
||||
variant: "default" as const,
|
||||
icon: DollarSign,
|
||||
disabled: isChangingStatus,
|
||||
});
|
||||
if (clientEmail) {
|
||||
actions.push({
|
||||
key: "resend",
|
||||
label: "Resend Invoice",
|
||||
action: handleSendEmail,
|
||||
variant: "outline" as const,
|
||||
icon: Send,
|
||||
disabled: sendEmail.isPending,
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
key: "backToSent",
|
||||
label: "Mark as Sent",
|
||||
action: () => handleStatusUpdate("sent"),
|
||||
variant: "outline" as const,
|
||||
icon: Clock,
|
||||
disabled: isChangingStatus,
|
||||
});
|
||||
break;
|
||||
|
||||
case "paid":
|
||||
// Paid invoices can be reverted if needed (rare cases)
|
||||
actions.push({
|
||||
key: "revert",
|
||||
label: "Revert to Sent",
|
||||
action: () => handleStatusUpdate("sent"),
|
||||
variant: "outline" as const,
|
||||
icon: RefreshCw,
|
||||
disabled: isChangingStatus,
|
||||
requireConfirmation: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
const actions = getAvailableActions();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<StatusIcon className="h-5 w-5" />
|
||||
Invoice Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Current Status Display */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className={statusConfig.color} variant="secondary">
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{statusConfig.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Overdue Warning */}
|
||||
{isOverdue && (
|
||||
<div className="bg-destructive/10 text-destructive flex items-center gap-2 p-3">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{daysPastDue} day{daysPastDue !== 1 ? "s" : ""} overdue
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Due Date Info */}
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
Due:{" "}
|
||||
{new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(dueDate))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{actions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-foreground text-sm font-medium">
|
||||
Available Actions:
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{actions.map((action) => {
|
||||
const ActionIcon = action.icon;
|
||||
|
||||
if (action.requireConfirmation) {
|
||||
return (
|
||||
<AlertDialog key={action.key}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant={action.variant}
|
||||
size="sm"
|
||||
disabled={action.disabled}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ActionIcon className="mr-2 h-4 w-4" />
|
||||
{action.label}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Confirm Status Change
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to change this invoice status?
|
||||
This action may affect your records.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={action.action}>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant={action.variant}
|
||||
size="sm"
|
||||
onClick={action.action}
|
||||
disabled={action.disabled}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
{action.disabled &&
|
||||
(action.key === "send" || action.key === "resend") ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : action.disabled &&
|
||||
(action.key === "markPaid" ||
|
||||
action.key === "backToDraft" ||
|
||||
action.key === "backToSent") ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ActionIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Email Warning */}
|
||||
{!clientEmail && effectiveStatus !== "paid" && (
|
||||
<div className="bg-muted text-muted-foreground p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
No email address on file for this client
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs">
|
||||
Add an email address to the client to enable sending invoices.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Building2,
|
||||
Clock,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
ScrollText,
|
||||
Search,
|
||||
Shield,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useDeferredValue, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { EmptyState } from "~/components/layout/page-layout";
|
||||
import {
|
||||
PageTabs,
|
||||
PageTabsContent,
|
||||
PageTabsList,
|
||||
PageTabsTrigger,
|
||||
} from "~/components/layout/page-tabs";
|
||||
import { dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
"user.profile_updated": "Profile updated",
|
||||
"user.role_updated": "Role updated",
|
||||
"user.password_reset_sent": "Password reset sent",
|
||||
"platform.pdf_settings_updated": "PDF settings updated",
|
||||
};
|
||||
|
||||
function formatAction(action: string) {
|
||||
return ACTION_LABELS[action] ?? action;
|
||||
}
|
||||
|
||||
function AdminOverview() {
|
||||
const { data: stats, isLoading, error } = api.admin.getStats.useQuery();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Platform overview</CardTitle>
|
||||
<CardDescription>Unable to load statistics.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: "Total users",
|
||||
value: stats?.totalUsers ?? 0,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: `Active (${stats?.activeUserWindowDays ?? 30}d)`,
|
||||
value: stats?.activeUsers ?? 0,
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
label: "Administrators",
|
||||
value: stats?.adminCount ?? 0,
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
label: "Invoices",
|
||||
value: stats?.totalInvoices ?? 0,
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
label: "Businesses",
|
||||
value: stats?.totalBusinesses ?? 0,
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
label: "Clients",
|
||||
value: stats?.totalClients ?? 0,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: "Time entries",
|
||||
value: stats?.totalTimeEntries ?? 0,
|
||||
icon: Clock,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
Platform overview
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Aggregate counts only — no customer data, credentials, or bulk PII.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading statistics…</p>
|
||||
) : (
|
||||
<div className={dashboardStatGridClass}>
|
||||
{statCards.map((stat) => (
|
||||
<Card key={stat.label}>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wide uppercase">
|
||||
<stat.icon className="h-3.5 w-3.5" />
|
||||
{stat.label}
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold">{stat.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type EditUserState = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: "user" | "admin";
|
||||
};
|
||||
|
||||
function AdminUsers() {
|
||||
const [search, setSearch] = useState("");
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [editUser, setEditUser] = useState<EditUserState | null>(null);
|
||||
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||
const [resetUserName, setResetUserName] = useState("");
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data, isLoading, error, isFetching } = api.admin.listUsers.useQuery({
|
||||
search: deferredSearch || undefined,
|
||||
offset,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const updateUserMutation = api.admin.updateUser.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("User updated");
|
||||
setEditUser(null);
|
||||
void utils.admin.listUsers.invalidate();
|
||||
void utils.admin.listAuditLog.invalidate();
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
toast.error(mutationError.message);
|
||||
},
|
||||
});
|
||||
|
||||
const sendResetMutation = api.admin.sendPasswordReset.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.emailSent) {
|
||||
toast.success("Password reset email sent");
|
||||
} else {
|
||||
toast.warning(
|
||||
"Reset token created, but email could not be sent. Check Resend configuration.",
|
||||
);
|
||||
}
|
||||
setResetUserId(null);
|
||||
void utils.admin.listAuditLog.invalidate();
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
toast.error(mutationError.message);
|
||||
},
|
||||
});
|
||||
|
||||
const users = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Users</CardTitle>
|
||||
<CardDescription>Administrative access is required.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="text-primary h-5 w-5" />
|
||||
Users
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Search accounts, edit profiles, and manage access.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setOffset(0);
|
||||
}}
|
||||
placeholder="Search by name or email…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading users…</p>
|
||||
) : users.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Users className="h-6 w-6" />}
|
||||
title="No users found"
|
||||
description={
|
||||
deferredSearch
|
||||
? "Try a different search term."
|
||||
: "No accounts have been created yet."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-border divide-y border">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium">{user.name}</p>
|
||||
<Badge
|
||||
variant={user.role === "admin" ? "default" : "secondary"}
|
||||
>
|
||||
{user.role}
|
||||
</Badge>
|
||||
{user.emailVerified ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Verified
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Joined{" "}
|
||||
{new Date(user.createdAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setEditUser({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role as "user" | "admin",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setResetUserId(user.id);
|
||||
setResetUserName(user.name);
|
||||
}}
|
||||
>
|
||||
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
|
||||
Reset password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{total > PAGE_SIZE ? (
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Page {currentPage} of {totalPages} · {total} users
|
||||
{isFetching ? " · Updating…" : ""}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset((value) => Math.max(0, value - PAGE_SIZE))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={offset + PAGE_SIZE >= total}
|
||||
onClick={() => setOffset((value) => value + PAGE_SIZE)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={editUser != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditUser(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit user</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editUser ? (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
updateUserMutation.mutate({
|
||||
userId: editUser.id,
|
||||
name: editUser.name,
|
||||
email: editUser.email,
|
||||
role: editUser.role,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-user-name">Name</Label>
|
||||
<Input
|
||||
id="edit-user-name"
|
||||
value={editUser.name}
|
||||
onChange={(event) =>
|
||||
setEditUser((current) =>
|
||||
current
|
||||
? { ...current, name: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-user-email">Email</Label>
|
||||
<Input
|
||||
id="edit-user-email"
|
||||
type="email"
|
||||
value={editUser.email}
|
||||
onChange={(event) =>
|
||||
setEditUser((current) =>
|
||||
current
|
||||
? { ...current, email: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-user-role">Role</Label>
|
||||
<Select
|
||||
value={editUser.role}
|
||||
onValueChange={(role) =>
|
||||
setEditUser((current) =>
|
||||
current
|
||||
? { ...current, role: role as "user" | "admin" }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="edit-user-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditUser(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateUserMutation.isPending}>
|
||||
{updateUserMutation.isPending ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={resetUserId != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setResetUserId(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Send password reset?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
A password reset email will be sent to{" "}
|
||||
<span className="font-medium">{resetUserName}</span>. The link
|
||||
expires in 24 hours.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={sendResetMutation.isPending}
|
||||
onClick={() => {
|
||||
if (resetUserId) {
|
||||
sendResetMutation.mutate({ userId: resetUserId });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sendResetMutation.isPending ? "Sending…" : "Send reset email"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminAuditLog() {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const { data, isLoading, error, isFetching } = api.admin.listAuditLog.useQuery(
|
||||
{
|
||||
offset,
|
||||
limit: PAGE_SIZE,
|
||||
},
|
||||
);
|
||||
|
||||
const entries = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit log</CardTitle>
|
||||
<CardDescription>Administrative access is required.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ScrollText className="text-primary h-5 w-5" />
|
||||
Audit log
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Recent administrative actions across the platform.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading audit log…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<ScrollText className="h-6 w-6" />}
|
||||
title="No audit events yet"
|
||||
description="Administrative actions will appear here."
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-border divide-y border">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="space-y-1 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
{formatAction(entry.action)}
|
||||
</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{entry.targetType}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{entry.actor?.name ?? "Unknown admin"} ·{" "}
|
||||
{new Date(entry.createdAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{entry.targetId ? ` · target ${entry.targetId.slice(0, 8)}…` : ""}
|
||||
</p>
|
||||
{entry.metadata &&
|
||||
Object.keys(entry.metadata).length > 0 ? (
|
||||
<p className="text-muted-foreground font-mono text-xs break-all">
|
||||
{JSON.stringify(entry.metadata)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{total > PAGE_SIZE ? (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Page {currentPage} of {totalPages} · {total} events
|
||||
{isFetching ? " · Updating…" : ""}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset((value) => Math.max(0, value - PAGE_SIZE))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={offset + PAGE_SIZE >= total}
|
||||
onClick={() => setOffset((value) => value + PAGE_SIZE)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdministrationContent() {
|
||||
return (
|
||||
<PageTabs defaultValue="overview">
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
|
||||
<PageTabsTrigger value="users">Users</PageTabsTrigger>
|
||||
<PageTabsTrigger value="audit">Audit log</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
<PageTabsContent value="overview">
|
||||
<AdminOverview />
|
||||
</PageTabsContent>
|
||||
|
||||
<PageTabsContent value="users">
|
||||
<AdminUsers />
|
||||
</PageTabsContent>
|
||||
|
||||
<PageTabsContent value="audit">
|
||||
<AdminAuditLog />
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { AdministrationContent } from "./_components/administration-content";
|
||||
|
||||
export default async function AdministrationPage() {
|
||||
const session = await getOptionalServerSessionFromHeaders();
|
||||
|
||||
if (session?.user) {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, session.user.id),
|
||||
columns: { role: true },
|
||||
});
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Administration"
|
||||
description="Platform statistics, user management, and audit logging"
|
||||
/>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
||||
<AdministrationContent />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { BusinessForm } from "~/components/forms/business-form";
|
||||
|
||||
export default function EditBusinessPage() {
|
||||
const params = useParams();
|
||||
const businessId = Array.isArray(params?.id) ? params.id[0] : params?.id;
|
||||
if (!businessId) return null;
|
||||
|
||||
return <BusinessForm businessId={businessId} mode="edit" />;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { api } from "~/trpc/server";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
DashboardPage,
|
||||
dashboardGapClass,
|
||||
dashboardGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Edit,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Building,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Globe,
|
||||
Hash,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
|
||||
interface BusinessDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function BusinessDetailPage({
|
||||
params,
|
||||
}: BusinessDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const business = await api.businesses.getById({ id });
|
||||
|
||||
if (!business) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
|
||||
description="View business details and information"
|
||||
>
|
||||
<Button asChild variant="outline" className="shadow-sm">
|
||||
<Link href="/dashboard/entities?tab=businesses">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
<span>Back to Businesses</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="default" className="shadow-md">
|
||||
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<span>Edit Business</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
{/* Business Information Card */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{business.logoStorageKey ? (
|
||||
<div className="bg-muted border-border/40 flex h-9 max-w-32 shrink-0 items-center justify-center overflow-hidden border px-1.5 py-1">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
|
||||
<img
|
||||
src={`/api/business-logo/${business.id}`}
|
||||
alt={`${business.name} logo`}
|
||||
className="h-full w-auto max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Building className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<span>Business Information</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Contact Information */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
Contact Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{business.email && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Email
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{business.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{business.phone && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Phone className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Phone
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{business.phone}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{business.website && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Globe className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Website
|
||||
</p>
|
||||
<a
|
||||
href={business.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary text-sm hover:underline"
|
||||
>
|
||||
{business.website}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{business.taxId && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Hash className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Tax ID
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{business.taxId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{(business.addressLine1 ?? business.city ?? business.state) && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
Business Address
|
||||
</h3>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<MapPin className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
{business.addressLine1 && (
|
||||
<p className="text-foreground">
|
||||
{business.addressLine1}
|
||||
</p>
|
||||
)}
|
||||
{business.addressLine2 && (
|
||||
<p className="text-foreground">
|
||||
{business.addressLine2}
|
||||
</p>
|
||||
)}
|
||||
{(business.city ??
|
||||
business.state ??
|
||||
business.postalCode) && (
|
||||
<p className="text-foreground">
|
||||
{[
|
||||
business.city,
|
||||
business.state,
|
||||
business.postalCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{business.country && (
|
||||
<p className="text-foreground">{business.country}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Business Metadata */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">Business Details</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Calendar className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Business Added
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{formatDate(business.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{business.nickname && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Building className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Nickname
|
||||
</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Internal only
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-foreground text-sm">
|
||||
{business.nickname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default Business Badge */}
|
||||
{business.isDefault && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Building className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Status
|
||||
</p>
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-primary/10 text-primary"
|
||||
>
|
||||
Default Business
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Settings & Actions Card */}
|
||||
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Building className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<span>Quick Actions</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Business
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<DollarSign className="mr-2 h-4 w-4" />
|
||||
Create Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Information Card */}
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">About This Business</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-muted-foreground space-y-3 text-sm">
|
||||
<p>
|
||||
This business profile is used for generating invoices and
|
||||
represents your company information to clients.
|
||||
</p>
|
||||
{business.isDefault && (
|
||||
<p className="text-primary">
|
||||
This is your default business and will be automatically
|
||||
selected when creating new invoices.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
||||
import { Building, Pencil, Trash2, ExternalLink, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Type for business data
|
||||
interface Business {
|
||||
id: string;
|
||||
name: string;
|
||||
nickname: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
addressLine1: string | null;
|
||||
addressLine2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
postalCode: string | null;
|
||||
country: string | null;
|
||||
website: string | null;
|
||||
taxId: string | null;
|
||||
logoUrl: string | null;
|
||||
logoStorageKey: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
interface BusinessesDataTableProps {
|
||||
businesses: Business[];
|
||||
}
|
||||
|
||||
export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||
const router = useRouter();
|
||||
const [businessToDelete, setBusinessToDelete] = useState<Business | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const searchableBusinesses = businesses.map((b) => ({
|
||||
...b,
|
||||
searchValue: `${b.name} ${b.nickname ?? ""}`.trim(),
|
||||
}));
|
||||
|
||||
const deleteBusinessMutation = api.businesses.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Business deleted successfully");
|
||||
setBusinessToDelete(null);
|
||||
void utils.businesses.getAll.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete business: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!businessToDelete) return;
|
||||
deleteBusinessMutation.mutate({ id: businessToDelete.id });
|
||||
};
|
||||
|
||||
const handleRowClick = (business: Business) => {
|
||||
router.push(`/dashboard/businesses/${business.id}`);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Business>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const business = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 hidden h-8 w-8 shrink-0 items-center justify-center overflow-hidden p-2 sm:flex">
|
||||
{business.logoStorageKey ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset
|
||||
<img
|
||||
src={`/api/business-logo/${business.id}`}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Building className="text-primary h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{business.name}</p>
|
||||
<p className="text-muted-foreground truncate text-sm">
|
||||
{business.nickname ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Email" />
|
||||
),
|
||||
cell: ({ row }) => row.original.email ?? "—",
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "phone",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Phone" />
|
||||
),
|
||||
cell: ({ row }) => row.original.phone ?? "—",
|
||||
meta: {
|
||||
headerClassName: "hidden md:table-cell",
|
||||
cellClassName: "hidden md:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "website",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Website" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const website = row.original.website;
|
||||
if (!website) return "—";
|
||||
|
||||
// Add https:// if not present
|
||||
const url = website.startsWith("http") ? website : `https://${website}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: Show full URL */}
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hidden hover:underline sm:inline"
|
||||
data-action-button="true"
|
||||
>
|
||||
{website}
|
||||
</a>
|
||||
{/* Mobile: Show link button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 sm:hidden"
|
||||
asChild
|
||||
data-action-button="true"
|
||||
>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "searchValue",
|
||||
header: "Search",
|
||||
cell: () => null,
|
||||
meta: {
|
||||
headerClassName: "hidden",
|
||||
cellClassName: "hidden",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const business = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
onClick={() => setBusinessToDelete(business)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={searchableBusinesses}
|
||||
searchKey="searchValue"
|
||||
searchPlaceholder="Search by name or nickname..."
|
||||
emptyTitle="Create your first business"
|
||||
emptyDescription="Set up a business profile for invoices, branding, and tax details."
|
||||
emptyIcon={<Building className="h-6 w-6" />}
|
||||
emptyAction={
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/businesses/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add business
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog
|
||||
open={!!businessToDelete}
|
||||
onOpenChange={(open) => !open && setBusinessToDelete(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
business "{businessToDelete?.name}" and remove all
|
||||
associated data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setBusinessToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteBusinessMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
import { BusinessesDataTable } from "./businesses-data-table";
|
||||
|
||||
export function BusinessesTable() {
|
||||
const { data: businesses, isLoading } = api.businesses.getAll.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <DataTableSkeleton columns={7} rows={5} />;
|
||||
}
|
||||
|
||||
if (!businesses) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <BusinessesDataTable businesses={businesses} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { BusinessForm } from "~/components/forms/business-form";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
|
||||
export default function NewBusinessPage() {
|
||||
return (
|
||||
<HydrateClient>
|
||||
<BusinessForm mode="create" />
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function BusinessesPage() {
|
||||
redirect("/dashboard/entities?tab=businesses");
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { ClientForm } from "~/components/forms/client-form";
|
||||
|
||||
export default function EditClientPage() {
|
||||
const params = useParams();
|
||||
const clientId = Array.isArray(params?.id) ? params.id[0] : params?.id;
|
||||
if (!clientId) return null;
|
||||
|
||||
return <ClientForm clientId={clientId} mode="edit" />;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { api } from "~/trpc/server";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
DashboardPage,
|
||||
dashboardGapClass,
|
||||
dashboardGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import { cn } from "~/lib/utils";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Edit,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Building,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
|
||||
interface ClientDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ClientDetailPage({
|
||||
params,
|
||||
}: ClientDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const client = await api.clients.getById({ id });
|
||||
|
||||
if (!client) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const totalInvoiced =
|
||||
client.invoices?.reduce((sum, invoice) => sum + invoice.totalAmount, 0) ||
|
||||
0;
|
||||
const paidInvoices =
|
||||
client.invoices?.filter((invoice) => invoice.status === "paid").length || 0;
|
||||
const pendingInvoices =
|
||||
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
|
||||
|
||||
return (
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title={client.name}
|
||||
description="View client details and information"
|
||||
>
|
||||
<Button asChild variant="outline" className="shadow-sm">
|
||||
<Link href="/dashboard/entities?tab=clients">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
<span>Back to Clients</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="default" className="shadow-md">
|
||||
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<span>Edit Client</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
{/* Client Information Card */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Building className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<span>Contact Information</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{client.email && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Email
|
||||
</p>
|
||||
<p className="text-foreground text-sm">{client.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{client.phone && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Phone className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Phone
|
||||
</p>
|
||||
<p className="text-foreground text-sm">{client.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{(client.addressLine1 ?? client.city ?? client.state) && (
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">Client Address</h3>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<MapPin className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
{client.addressLine1 && (
|
||||
<p className="text-foreground">{client.addressLine1}</p>
|
||||
)}
|
||||
{client.addressLine2 && (
|
||||
<p className="text-foreground">{client.addressLine2}</p>
|
||||
)}
|
||||
{(client.city ?? client.state ?? client.postalCode) && (
|
||||
<p className="text-foreground">
|
||||
{[client.city, client.state, client.postalCode]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{client.country && (
|
||||
<p className="text-foreground">{client.country}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Client Since */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">Client Details</h3>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Calendar className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Client Since
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{formatDate(client.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Stats Card */}
|
||||
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<DollarSign className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<span>Invoice Summary</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-primary text-3xl font-bold">
|
||||
{formatCurrency(totalInvoiced)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">Total Invoiced</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-foreground text-xl font-semibold">
|
||||
{paidInvoices}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">Paid</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-foreground text-xl font-semibold">
|
||||
{pendingInvoices}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Invoices */}
|
||||
{client.invoices && client.invoices.length > 0 && (
|
||||
<Card className="">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<DollarSign className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<span>Recent Invoices</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{client.invoices.slice(0, 3).map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="card-secondary hover:bg-muted/50 border p-3 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground font-medium break-words">
|
||||
{invoice.invoiceNumber}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{formatDate(invoice.issueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2 self-start sm:flex-col sm:items-end sm:gap-1">
|
||||
<p className="text-foreground font-semibold">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</p>
|
||||
<Badge
|
||||
variant={
|
||||
getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
) === "paid"
|
||||
? "default"
|
||||
: getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
) === "sent"
|
||||
? "secondary"
|
||||
: getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
) === "overdue"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
||||
import { UserPlus, Pencil, Trash2, Plus, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Type for client data
|
||||
interface Client {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
addressLine1: string | null;
|
||||
addressLine2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
postalCode: string | null;
|
||||
country: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
interface ClientsDataTableProps {
|
||||
clients: Client[];
|
||||
}
|
||||
|
||||
const formatAddress = (client: Client) => {
|
||||
const parts = [
|
||||
client.addressLine1,
|
||||
client.addressLine2,
|
||||
client.city,
|
||||
client.state,
|
||||
client.postalCode,
|
||||
].filter(Boolean);
|
||||
return parts.join(", ") || "—";
|
||||
};
|
||||
|
||||
export function ClientsDataTable({
|
||||
clients: initialClients,
|
||||
}: ClientsDataTableProps) {
|
||||
const router = useRouter();
|
||||
const [clients, setClients] = useState(initialClients);
|
||||
const [clientToDelete, setClientToDelete] = useState<Client | null>(null);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteClientMutation = api.clients.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Client deleted successfully");
|
||||
setClients(clients.filter((c) => c.id !== clientToDelete?.id));
|
||||
setClientToDelete(null);
|
||||
void utils.clients.getAll.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete client: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!clientToDelete) return;
|
||||
deleteClientMutation.mutate({ id: clientToDelete.id });
|
||||
};
|
||||
|
||||
const handleRowClick = (client: Client) => {
|
||||
router.push(`/dashboard/clients/${client.id}`);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Client>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const client = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 hidden p-2 sm:flex">
|
||||
<UserPlus className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{client.name}</p>
|
||||
<p className="text-muted-foreground truncate text-sm">
|
||||
{client.email ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "phone",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Phone" />
|
||||
),
|
||||
cell: ({ row }) => row.original.phone ?? "—",
|
||||
meta: {
|
||||
headerClassName: "hidden md:table-cell",
|
||||
cellClassName: "hidden md:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "address",
|
||||
header: "Address",
|
||||
cell: ({ row }) => formatAddress(row.original),
|
||||
meta: {
|
||||
headerClassName: "hidden lg:table-cell",
|
||||
cellClassName: "hidden lg:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt");
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(new Date(date as Date));
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden xl:table-cell",
|
||||
cellClassName: "hidden xl:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const client = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
onClick={() => setClientToDelete(client)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={clients}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search clients..."
|
||||
emptyTitle="Create your first client"
|
||||
emptyDescription="Add clients to bill them and keep contact details in one place."
|
||||
emptyIcon={<Users className="h-6 w-6" />}
|
||||
emptyAction={
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add client
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog
|
||||
open={!!clientToDelete}
|
||||
onOpenChange={(open) => !open && setClientToDelete(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
client "{clientToDelete?.name}" and remove all
|
||||
associated data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setClientToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteClientMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
import { ClientsDataTable } from "./clients-data-table";
|
||||
|
||||
export function ClientsTable() {
|
||||
const { data: clients, isLoading } = api.clients.getAll.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <DataTableSkeleton columns={5} rows={8} />;
|
||||
}
|
||||
|
||||
if (!clients) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ClientsDataTable clients={clients} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ClientForm } from "~/components/forms/client-form";
|
||||
|
||||
export default function NewClientPage() {
|
||||
return <ClientForm mode="create" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function ClientsPage() {
|
||||
redirect("/dashboard/entities?tab=clients");
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
PageTabs,
|
||||
PageTabsContent,
|
||||
PageTabsList,
|
||||
PageTabsTrigger,
|
||||
} from "~/components/layout/page-tabs";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ClientsDataTable } from "../../clients/_components/clients-data-table";
|
||||
import { BusinessesDataTable } from "../../businesses/_components/businesses-data-table";
|
||||
import type { RouterOutputs } from "~/trpc/react";
|
||||
|
||||
type EntityTab = "clients" | "businesses";
|
||||
|
||||
type Client = RouterOutputs["clients"]["getAll"][number];
|
||||
type Business = RouterOutputs["businesses"]["getAll"][number];
|
||||
|
||||
export function EntitiesView({
|
||||
initialTab,
|
||||
clients,
|
||||
businesses,
|
||||
}: {
|
||||
initialTab: EntityTab;
|
||||
clients: Client[];
|
||||
businesses: Business[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const tab: EntityTab =
|
||||
searchParams.get("tab") === "businesses" ? "businesses" : initialTab;
|
||||
|
||||
function handleTabChange(value: string) {
|
||||
const next = value === "businesses" ? "businesses" : "clients";
|
||||
router.replace(`/dashboard/entities?tab=${next}`, { scroll: false });
|
||||
}
|
||||
|
||||
const addHref =
|
||||
tab === "clients" ? "/dashboard/clients/new" : "/dashboard/businesses/new";
|
||||
const addLabel = tab === "clients" ? "Add client" : "Add business";
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPageHeader
|
||||
title="Entities"
|
||||
description="Clients you bill and businesses you send from"
|
||||
>
|
||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||
<Link href={addHref}>
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>{addLabel}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<PageTabs value={tab} onValueChange={handleTabChange}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="clients">Clients</PageTabsTrigger>
|
||||
<PageTabsTrigger value="businesses">Businesses</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
<PageTabsContent value="clients">
|
||||
{tab === "clients" ? <ClientsDataTable clients={clients} /> : null}
|
||||
</PageTabsContent>
|
||||
|
||||
<PageTabsContent value="businesses">
|
||||
{tab === "businesses" ? (
|
||||
<BusinessesDataTable businesses={businesses} />
|
||||
) : null}
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { api } from "~/trpc/server";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { EntitiesView } from "./_components/entities-view";
|
||||
|
||||
export default async function EntitiesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const initialTab = params.tab === "businesses" ? "businesses" : "clients";
|
||||
|
||||
const [clients, businesses] = await Promise.all([
|
||||
api.clients.getAll(),
|
||||
api.businesses.getAll(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
<EntitiesView
|
||||
initialTab={initialTab}
|
||||
clients={clients}
|
||||
businesses={businesses}
|
||||
/>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
DashboardPage,
|
||||
dashboardStatGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { ExpenseReceiptsPanel } from "~/components/expenses/expense-receipts-panel";
|
||||
import { ExpenseReceiptIndicator } from "~/components/expenses/expense-receipt-indicator";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Receipt,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
interface ExpenseFormData {
|
||||
date: Date;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
category: string;
|
||||
billable: boolean;
|
||||
reimbursable: boolean;
|
||||
taxDeductible: boolean;
|
||||
notes: string;
|
||||
clientId: string;
|
||||
businessId: string;
|
||||
}
|
||||
|
||||
const defaultForm: ExpenseFormData = {
|
||||
date: new Date(),
|
||||
description: "",
|
||||
amount: 0,
|
||||
currency: "USD",
|
||||
category: "",
|
||||
billable: false,
|
||||
reimbursable: false,
|
||||
taxDeductible: false,
|
||||
notes: "",
|
||||
clientId: "",
|
||||
businessId: "",
|
||||
};
|
||||
|
||||
type ExpenseDialogMode = "create" | "view" | "edit";
|
||||
type ExpenseFilter = "all" | "billable" | "deductible" | "receipts";
|
||||
|
||||
function expenseToForm(
|
||||
expense: {
|
||||
date: Date | string;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
category: string | null;
|
||||
billable: boolean;
|
||||
reimbursable: boolean;
|
||||
taxDeductible: boolean | null;
|
||||
notes: string | null;
|
||||
clientId: string | null;
|
||||
businessId: string | null;
|
||||
},
|
||||
defaultBusinessId: string,
|
||||
): ExpenseFormData {
|
||||
return {
|
||||
date: new Date(expense.date),
|
||||
description: expense.description,
|
||||
amount: expense.amount,
|
||||
currency: expense.currency,
|
||||
category: expense.category ?? "",
|
||||
billable: expense.billable,
|
||||
reimbursable: expense.reimbursable,
|
||||
taxDeductible: expense.taxDeductible ?? false,
|
||||
notes: expense.notes ?? "",
|
||||
clientId: expense.clientId ?? "",
|
||||
businessId: expense.businessId ?? defaultBusinessId,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ExpensesPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<ExpenseDialogMode>("create");
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [businessFilter, setBusinessFilter] = useState("all");
|
||||
const [expenseFilter, setExpenseFilter] = useState<ExpenseFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: businesses = [] } = api.businesses.getAll.useQuery();
|
||||
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery(
|
||||
businessFilter === "all" ? undefined : { businessId: businessFilter },
|
||||
);
|
||||
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||
|
||||
const defaultBusinessId = useMemo(
|
||||
() => businesses.find((b) => b.isDefault)?.id ?? businesses[0]?.id ?? "",
|
||||
[businesses],
|
||||
);
|
||||
|
||||
const create = api.expenses.create.useMutation({
|
||||
onSuccess: (expense) => {
|
||||
if (!expense) return;
|
||||
toast.success("Expense saved — you can now attach receipts");
|
||||
void utils.expenses.getAll.invalidate();
|
||||
setEditId(expense.id);
|
||||
setDialogMode("edit");
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const update = api.expenses.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Expense updated");
|
||||
void utils.expenses.getAll.invalidate();
|
||||
setOpen(false);
|
||||
setEditId(null);
|
||||
setDialogMode("create");
|
||||
setForm(defaultForm);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const del = api.expenses.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Expense deleted");
|
||||
void utils.expenses.getAll.invalidate();
|
||||
setDeleteId(null);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const closeDialog = () => {
|
||||
setOpen(false);
|
||||
setEditId(null);
|
||||
setDialogMode("create");
|
||||
setForm(defaultForm);
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
setEditId(null);
|
||||
setDialogMode("create");
|
||||
setForm({ ...defaultForm, businessId: defaultBusinessId });
|
||||
setOpen(true);
|
||||
};
|
||||
const handleView = (expense: (typeof expenses)[0]) => {
|
||||
setEditId(expense.id);
|
||||
setDialogMode("view");
|
||||
setForm(expenseToForm(expense, defaultBusinessId));
|
||||
setOpen(true);
|
||||
};
|
||||
const handleEdit = (expense: (typeof expenses)[0]) => {
|
||||
setEditId(expense.id);
|
||||
setDialogMode("edit");
|
||||
setForm(expenseToForm(expense, defaultBusinessId));
|
||||
setOpen(true);
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
if (!form.description.trim()) {
|
||||
toast.error("Description is required");
|
||||
return;
|
||||
}
|
||||
if (form.amount <= 0) {
|
||||
toast.error("Amount must be greater than 0");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
...form,
|
||||
clientId: form.clientId || undefined,
|
||||
businessId: form.businessId || undefined,
|
||||
category: form.category || undefined,
|
||||
notes: form.notes || undefined,
|
||||
taxDeductible: form.taxDeductible,
|
||||
};
|
||||
if (editId) update.mutate({ id: editId, ...payload });
|
||||
else create.mutate(payload);
|
||||
};
|
||||
|
||||
const filteredExpenses = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
return expenses.filter((expense) => {
|
||||
if (expenseFilter === "billable" && !expense.billable) return false;
|
||||
if (expenseFilter === "deductible" && !expense.taxDeductible)
|
||||
return false;
|
||||
if (expenseFilter === "receipts" && expense.receiptCount === 0)
|
||||
return false;
|
||||
if (!needle) return true;
|
||||
return [
|
||||
expense.description,
|
||||
expense.category,
|
||||
expense.notes,
|
||||
expense.business?.name,
|
||||
expense.client?.name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((value) => value?.toLowerCase().includes(needle));
|
||||
});
|
||||
}, [expenseFilter, expenses, search]);
|
||||
|
||||
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
||||
const visibleTotal = filteredExpenses.reduce((s, e) => s + e.amount, 0);
|
||||
const billableTotal = expenses
|
||||
.filter((e) => e.billable)
|
||||
.reduce((s, e) => s + e.amount, 0);
|
||||
const deductibleTotal = expenses
|
||||
.filter((e) => e.taxDeductible)
|
||||
.reduce((s, e) => s + e.amount, 0);
|
||||
const withReceipts = expenses.filter((e) => e.receiptCount > 0).length;
|
||||
const hasActiveFilters =
|
||||
search.trim().length > 0 ||
|
||||
expenseFilter !== "all" ||
|
||||
businessFilter !== "all";
|
||||
|
||||
const isViewMode = dialogMode === "view";
|
||||
const isEditMode = dialogMode === "edit";
|
||||
const isCreateMode = dialogMode === "create";
|
||||
|
||||
const dialogTitle = isCreateMode
|
||||
? "Add expense"
|
||||
: isViewMode
|
||||
? "View expense"
|
||||
: "Edit expense";
|
||||
|
||||
const businessName =
|
||||
businesses.find((b) => b.id === form.businessId)?.name ??
|
||||
(form.businessId ? "Unknown business" : "Default business");
|
||||
const clientName = form.clientId
|
||||
? (clients.find((c) => c.id === form.clientId)?.name ?? "Unknown client")
|
||||
: "No client";
|
||||
|
||||
const formattedDate = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(form.date);
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Expenses"
|
||||
description="Track billable and non-billable expenses"
|
||||
>
|
||||
<Button
|
||||
onClick={handleOpen}
|
||||
variant="default"
|
||||
className="hover-lift shadow-md"
|
||||
>
|
||||
<Plus className="mr-2 h-5 w-5" /> Add Expense
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<div className={dashboardStatGridClass}>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
Total
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold">
|
||||
{formatCurrency(totalExpenses)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
Billable
|
||||
</p>
|
||||
<p className="text-primary mt-1 text-2xl font-bold">
|
||||
{formatCurrency(billableTotal)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
Deductible
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold text-green-600">
|
||||
{formatCurrency(deductibleTotal)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
With receipts
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold">{withReceipts}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Receipt className="h-5 w-5" /> Expenses
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{filteredExpenses.length === expenses.length
|
||||
? `${expenses.length} recorded`
|
||||
: `${filteredExpenses.length} of ${expenses.length} shown`}
|
||||
{filteredExpenses.length !== expenses.length
|
||||
? ` · ${formatCurrency(visibleTotal)} visible`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative sm:w-64">
|
||||
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search expenses"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={businessFilter} onValueChange={setBusinessFilter}>
|
||||
<SelectTrigger className="sm:w-52">
|
||||
<SelectValue placeholder="All businesses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All businesses</SelectItem>
|
||||
{businesses.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
["all", "All"] as const,
|
||||
["billable", "Billable"] as const,
|
||||
["deductible", "Deductible"] as const,
|
||||
["receipts", "With receipts"] as const,
|
||||
].map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
variant={expenseFilter === value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setExpenseFilter(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground p-6 text-center text-sm">
|
||||
Loading…
|
||||
</div>
|
||||
) : expenses.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Receipt className="h-6 w-6" />}
|
||||
title="Create your first expense"
|
||||
description="Track billable costs, reimbursements, and tax-deductible spending."
|
||||
action={
|
||||
<Button onClick={handleOpen}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add expense
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : filteredExpenses.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Search className="h-6 w-6" />}
|
||||
title="No matching expenses"
|
||||
description="Adjust the search or filters to bring expenses back into view."
|
||||
action={
|
||||
hasActiveFilters ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setExpenseFilter("all");
|
||||
setBusinessFilter("all");
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground hidden border-b px-4 py-2 text-xs font-medium tracking-wide uppercase sm:grid sm:grid-cols-[minmax(0,1fr)_104px_116px_44px] sm:gap-3">
|
||||
<span>Expense</span>
|
||||
<span className="text-center">Receipts</span>
|
||||
<span className="text-right">Amount</span>
|
||||
<span />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{filteredExpenses.map((expense) => (
|
||||
<div
|
||||
key={expense.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleView(expense)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleView(expense);
|
||||
}
|
||||
}}
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex cursor-pointer flex-col gap-3 p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none sm:grid sm:grid-cols-[minmax(0,1fr)_104px_116px_44px] sm:items-center sm:gap-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium">{expense.description}</p>
|
||||
{expense.billable && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Billable
|
||||
</Badge>
|
||||
)}
|
||||
{expense.reimbursable && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Reimbursable
|
||||
</Badge>
|
||||
)}
|
||||
{expense.taxDeductible && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-300 text-xs text-green-600"
|
||||
>
|
||||
Tax Deductible
|
||||
</Badge>
|
||||
)}
|
||||
{expense.category && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{expense.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(expense.date))}
|
||||
{expense.business ? ` · ${expense.business.name}` : ""}
|
||||
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||
</p>
|
||||
{expense.notes && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{expense.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center sm:justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-muted-foreground mr-2 text-xs sm:hidden">
|
||||
Receipts
|
||||
</span>
|
||||
<ExpenseReceiptIndicator
|
||||
expenseId={expense.id}
|
||||
receiptCount={expense.receiptCount}
|
||||
receiptPreview={expense.receiptPreview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="font-semibold sm:text-right">
|
||||
{formatCurrency(expense.amount, expense.currency)}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0"
|
||||
aria-label={`Actions for ${expense.description}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleView(expense)}>
|
||||
<Receipt className="mr-2 h-4 w-4" />
|
||||
View details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleEdit(expense)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteId(expense.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setEditId(null);
|
||||
setDialogMode("create");
|
||||
setForm(defaultForm);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
{isCreateMode && (
|
||||
<DialogDescription>
|
||||
Fill in the details below. You can attach receipts after saving.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{isViewMode ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Description
|
||||
</p>
|
||||
<p className="text-sm">{form.description}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Amount
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatCurrency(form.amount, form.currency)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Date
|
||||
</p>
|
||||
<p className="text-sm">{formattedDate}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Category
|
||||
</p>
|
||||
<p className="text-sm">{form.category || "None"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Business
|
||||
</p>
|
||||
<p className="text-sm">{businessName}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Client
|
||||
</p>
|
||||
<p className="text-sm">{clientName}</p>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Flags
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{form.billable ? (
|
||||
<Badge variant="secondary">Billable</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Not billable</Badge>
|
||||
)}
|
||||
{form.reimbursable ? (
|
||||
<Badge variant="outline">Reimbursable</Badge>
|
||||
) : null}
|
||||
{form.taxDeductible ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-300 text-green-600"
|
||||
>
|
||||
Tax deductible
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{form.notes ? (
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Notes
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{form.notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Description *</Label>
|
||||
<Input
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, description: e.target.value }))
|
||||
}
|
||||
placeholder="e.g. Laptop charger"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Amount *</Label>
|
||||
<NumberInput
|
||||
value={form.amount}
|
||||
onChange={(v) => setForm((p) => ({ ...p, amount: v }))}
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Currency</Label>
|
||||
<Select
|
||||
value={form.currency}
|
||||
onValueChange={(v) =>
|
||||
setForm((p) => ({ ...p, currency: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_CURRENCIES.map((c) => (
|
||||
<SelectItem key={c.code} value={c.code}>
|
||||
{c.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Date</Label>
|
||||
<DatePicker
|
||||
date={form.date}
|
||||
onDateChange={(d) =>
|
||||
setForm((p) => ({ ...p, date: d ?? new Date() }))
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select
|
||||
value={form.category || "none"}
|
||||
onValueChange={(v) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
category: v === "none" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{EXPENSE_CATEGORIES.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Business</Label>
|
||||
<Select
|
||||
value={form.businessId || "none"}
|
||||
onValueChange={(v) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
businessId: v === "none" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select business" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Default business</SelectItem>
|
||||
{businesses.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
{b.isDefault ? " (default)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Client (optional)</Label>
|
||||
<Select
|
||||
value={form.clientId || "none"}
|
||||
onValueChange={(v) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
clientId: v === "none" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No client</SelectItem>
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox
|
||||
checked={form.billable}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((p) => ({ ...p, billable: !!v }))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">Billable</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox
|
||||
checked={form.reimbursable}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((p) => ({ ...p, reimbursable: !!v }))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">Reimbursable</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox
|
||||
checked={form.taxDeductible}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((p) => ({ ...p, taxDeductible: !!v }))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">Tax Deductible</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes (optional)</Label>
|
||||
<Input
|
||||
value={form.notes}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, notes: e.target.value }))
|
||||
}
|
||||
placeholder="Additional details…"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ExpenseReceiptsPanel expenseId={editId} readOnly={isViewMode} />
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-3">
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={closeDialog}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setDialogMode("edit")}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={closeDialog}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleSubmit}
|
||||
disabled={create.isPending || update.isPending}
|
||||
>
|
||||
{create.isPending || update.isPending
|
||||
? "Saving…"
|
||||
: isEditMode
|
||||
? "Update"
|
||||
: "Save & add receipts"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Expense</DialogTitle>
|
||||
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteId && del.mutate({ id: deleteId })}
|
||||
disabled={del.isPending}
|
||||
>
|
||||
{del.isPending ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
DashboardPage,
|
||||
dashboardGapClass,
|
||||
dashboardGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function InvoiceDetailsSkeleton() {
|
||||
return (
|
||||
<DashboardPage className="pb-24">
|
||||
<DashboardPageHeader
|
||||
title="Loading..."
|
||||
description="View and manage invoice information"
|
||||
>
|
||||
<Skeleton className="h-10 w-10 sm:w-32" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</DashboardPageHeader>
|
||||
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
|
||||
{/* Invoice Header Skeleton */}
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm sm:space-y-0">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="hidden h-4 w-32 sm:block" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-left sm:text-right">
|
||||
<Skeleton className="mb-1 h-4 w-24 sm:ml-auto" />
|
||||
<Skeleton className="h-9 w-32 sm:ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Client & Business Info */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Client Skeleton */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business Skeleton */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Invoice Items Skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Item Rows */}
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i} className="bg-secondary/50 border-0">
|
||||
<CardContent className="p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Skeleton className="mb-2 h-5 w-3/4" />
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="bg-secondary rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-6 w-16" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||
<Card className="lg:sticky lg:top-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "~/components/data/data-table";
|
||||
import {
|
||||
formatLineItemDetail,
|
||||
isFixedLineItem,
|
||||
} from "~/lib/invoice-line-item";
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Type for invoice item data
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface InvoiceItemsTableProps {
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
const columns: ColumnDef<InvoiceItem>[] = [
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: "Date",
|
||||
cell: ({ row }) => formatDate(row.getValue("date")),
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: plain description */}
|
||||
<div className="hidden font-medium sm:block">{item.description}</div>
|
||||
{/* Mobile: description + date + hours @ rate stacked */}
|
||||
<div className="sm:hidden">
|
||||
<p className="font-medium">{item.description}</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{formatDate(item.date)} ·{" "}
|
||||
{formatLineItemDetail(item.hours, item.rate, formatCurrency)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "hours",
|
||||
header: "Hours",
|
||||
cell: ({ row }) => {
|
||||
const hours = row.getValue<number>("hours");
|
||||
return (
|
||||
<div className="text-right">{isFixedLineItem(hours) ? "—" : hours}</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "rate",
|
||||
header: "Rate",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<div className="text-right">
|
||||
{isFixedLineItem(item.hours)
|
||||
? "—"
|
||||
: `${formatCurrency(item.rate)}/hr`}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: "Amount",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-primary text-right font-medium">
|
||||
{formatCurrency(row.getValue("amount"))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function InvoiceItemsTable({ items }: InvoiceItemsTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
showSearch={false}
|
||||
showColumnVisibility={false}
|
||||
showPagination={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||
|
||||
interface InvoiceTimerCardProps {
|
||||
invoiceId: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
|
||||
return (
|
||||
<TimeClockPanel
|
||||
compact
|
||||
defaultClientId={clientId}
|
||||
defaultInvoiceId={invoiceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
interface PDFDownloadButtonProps {
|
||||
invoiceId: string;
|
||||
variant?: "default" | "outline" | "ghost" | "icon" | "secondary";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PDFDownloadButton({
|
||||
invoiceId,
|
||||
variant = "outline",
|
||||
className,
|
||||
}: PDFDownloadButtonProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
// Fetch invoice data when PDF generation is triggered
|
||||
const { refetch: fetchInvoice } = api.invoices.getById.useQuery(
|
||||
{ id: invoiceId },
|
||||
{ enabled: false },
|
||||
);
|
||||
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const handleDownloadPDF = async () => {
|
||||
if (isGenerating) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
// Fetch fresh invoice data
|
||||
const { data: invoiceData } = await fetchInvoice();
|
||||
|
||||
if (!invoiceData) {
|
||||
throw new Error("Invoice not found");
|
||||
}
|
||||
|
||||
// Map invoice to PDF format with currency support
|
||||
const pdfData = {
|
||||
invoiceNumber: invoiceData.invoiceNumber,
|
||||
invoicePrefix: invoiceData.invoicePrefix,
|
||||
issueDate: new Date(invoiceData.issueDate),
|
||||
dueDate: new Date(invoiceData.dueDate),
|
||||
status: invoiceData.status,
|
||||
totalAmount: invoiceData.totalAmount,
|
||||
taxRate: invoiceData.taxRate,
|
||||
currency: invoiceData.currency ?? "USD",
|
||||
notes: invoiceData.notes,
|
||||
business: invoiceData.business,
|
||||
client: invoiceData.client,
|
||||
items: invoiceData.items,
|
||||
};
|
||||
|
||||
await generateInvoicePDF(pdfData, {
|
||||
pdfTemplate: pdfSettings?.pdfTemplate,
|
||||
pdfAccentColor: pdfSettings?.pdfAccentColor,
|
||||
pdfFontFamily: pdfSettings?.pdfFontFamily,
|
||||
pdfNumericFontFamily: pdfSettings?.pdfNumericFontFamily,
|
||||
pdfFooterText: pdfSettings?.pdfFooterText,
|
||||
pdfShowLogo: pdfSettings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers,
|
||||
});
|
||||
toast.success("PDF downloaded successfully");
|
||||
} catch (error) {
|
||||
console.error("PDF generation error:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to generate PDF",
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (variant === "icon") {
|
||||
return (
|
||||
<Button
|
||||
onClick={handleDownloadPDF}
|
||||
disabled={isGenerating}
|
||||
variant="ghost"
|
||||
className={className}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleDownloadPDF}
|
||||
disabled={isGenerating}
|
||||
variant={variant}
|
||||
className={`shadow-sm ${className ?? ""}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
<span>Generating PDF...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
<span>Download PDF</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import InvoiceForm from "~/components/forms/invoice-form";
|
||||
import { api } from "~/trpc/server";
|
||||
|
||||
interface EditInvoicePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditInvoicePage({ params }: EditInvoicePageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const invoice = await api.invoices.getById({ id });
|
||||
if (invoice.status !== "draft") {
|
||||
redirect(`/dashboard/invoices/${id}?editBlocked=1`);
|
||||
}
|
||||
} catch {
|
||||
redirect("/dashboard/invoices");
|
||||
}
|
||||
|
||||
return <InvoiceForm invoiceId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
Building,
|
||||
Check,
|
||||
Copy,
|
||||
DollarSign,
|
||||
Edit,
|
||||
FileText,
|
||||
Link2,
|
||||
Link2Off,
|
||||
Loader2,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Plus,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { StatusBadge } from "~/components/data/status-badge";
|
||||
import {
|
||||
DashboardPage,
|
||||
dashboardGapClass,
|
||||
dashboardGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import {
|
||||
getEffectiveInvoiceStatus,
|
||||
isInvoiceOverdue,
|
||||
} from "~/lib/invoice-status";
|
||||
import { api } from "~/trpc/react";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
|
||||
import { PDFDownloadButton } from "./_components/pdf-download-button";
|
||||
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
|
||||
import { InvoiceTimerCard } from "./_components/invoice-timer-card";
|
||||
|
||||
const PAYMENT_METHODS = [
|
||||
{ value: "cash", label: "Cash" },
|
||||
{ value: "check", label: "Check" },
|
||||
{ value: "bank_transfer", label: "Bank Transfer" },
|
||||
{ value: "credit_card", label: "Credit Card" },
|
||||
{ value: "paypal", label: "PayPal" },
|
||||
{ value: "other", label: "Other" },
|
||||
] as const;
|
||||
|
||||
function methodLabel(method: string) {
|
||||
return PAYMENT_METHODS.find((m) => m.value === method)?.label ?? method;
|
||||
}
|
||||
|
||||
function daysSince(date: Date) {
|
||||
return Math.floor((Date.now() - new Date(date).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [recordPaymentOpen, setRecordPaymentOpen] = useState(false);
|
||||
const [reminderOpen, setReminderOpen] = useState(false);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [paymentAmount, setPaymentAmount] = useState("");
|
||||
const [paymentMethod, setPaymentMethod] = useState("other");
|
||||
const [paymentNotes, setPaymentNotes] = useState("");
|
||||
const [reminderMessage, setReminderMessage] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
|
||||
id: invoiceId,
|
||||
});
|
||||
const { data: payments, isLoading: paymentsLoading } =
|
||||
api.payments.getByInvoice.useQuery({ invoiceId });
|
||||
const utils = api.useUtils();
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("editBlocked") === "1") {
|
||||
toast.error("Only draft invoices can be edited");
|
||||
router.replace(`/dashboard/invoices/${invoiceId}`);
|
||||
}
|
||||
}, [searchParams, invoiceId, router]);
|
||||
|
||||
const invalidate = () => {
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
void utils.payments.getByInvoice.invalidate({ invoiceId });
|
||||
};
|
||||
|
||||
const deleteInvoice = api.invoices.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Invoice deleted");
|
||||
router.push("/dashboard/invoices");
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
|
||||
});
|
||||
|
||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to update status"),
|
||||
});
|
||||
|
||||
const createPayment = api.payments.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Payment recorded");
|
||||
setRecordPaymentOpen(false);
|
||||
setPaymentAmount("");
|
||||
setPaymentMethod("other");
|
||||
setPaymentNotes("");
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to record payment"),
|
||||
});
|
||||
|
||||
const deletePayment = api.payments.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Payment removed");
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to remove payment"),
|
||||
});
|
||||
|
||||
const generatePublicToken = api.invoices.generatePublicToken.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Share link generated");
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to generate link"),
|
||||
});
|
||||
|
||||
const revokePublicToken = api.invoices.revokePublicToken.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Share link revoked");
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to revoke link"),
|
||||
});
|
||||
|
||||
const sendReminder = api.invoices.sendReminder.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Reminder sent");
|
||||
setReminderOpen(false);
|
||||
setReminderMessage("");
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to send reminder"),
|
||||
});
|
||||
|
||||
const updateInvoice = api.invoices.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Reminder saved");
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to save reminder"),
|
||||
});
|
||||
|
||||
if (isLoading) return <InvoiceDetailsSkeleton />;
|
||||
if (!invoice) notFound();
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(
|
||||
new Date(date),
|
||||
);
|
||||
|
||||
const formatCurrency = (amount: number, currency = invoice.currency) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
|
||||
|
||||
const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0);
|
||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||
const total = subtotal + taxAmount;
|
||||
const totalPaid = (payments ?? []).reduce((s, p) => s + p.amount, 0);
|
||||
const balanceDue = total - totalPaid;
|
||||
const storedStatus = invoice.status as StoredInvoiceStatus;
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, invoice.dueDate);
|
||||
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
|
||||
const canSendReminder = effectiveStatus === "sent" || effectiveStatus === "overdue";
|
||||
|
||||
const publicUrl = invoice.publicToken
|
||||
? `${window.location.origin}/i/${invoice.publicToken}`
|
||||
: null;
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
if (!publicUrl) return;
|
||||
await navigator.clipboard.writeText(publicUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleRecordPayment = () => {
|
||||
const amount = parseFloat(paymentAmount);
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
toast.error("Enter a valid payment amount");
|
||||
return;
|
||||
}
|
||||
createPayment.mutate({
|
||||
invoiceId,
|
||||
amount,
|
||||
date: new Date(),
|
||||
method: paymentMethod as Parameters<typeof createPayment.mutate>[0]["method"],
|
||||
notes: paymentNotes || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardPage className="pb-24">
|
||||
<DashboardPageHeader
|
||||
title="Invoice Details"
|
||||
description="View and manage invoice information"
|
||||
>
|
||||
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
|
||||
{storedStatus === "draft" ? (
|
||||
<Button asChild variant="default" className="hover-lift">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-5 w-5" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</DashboardPageHeader>
|
||||
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
{/* Left Column */}
|
||||
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
|
||||
{/* Invoice Header */}
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<h2 className="text-foreground text-2xl font-bold break-words">
|
||||
{invoice.invoiceNumber}
|
||||
</h2>
|
||||
<StatusBadge status={effectiveStatus} />
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1 text-sm sm:space-y-0">
|
||||
<div className="sm:inline">Issued {formatDate(invoice.issueDate)}</div>
|
||||
<div className="sm:inline sm:before:content-['_•_']">
|
||||
Due {formatDate(invoice.dueDate)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-left sm:text-right">
|
||||
<p className="text-muted-foreground text-sm">Total Amount</p>
|
||||
<p className="text-primary text-3xl font-bold">{formatCurrency(total)}</p>
|
||||
{totalPaid > 0 && balanceDue > 0 && (
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
Balance due: {formatCurrency(balanceDue)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Overdue Alert */}
|
||||
{isOverdue && (
|
||||
<Card className="border-destructive/20 bg-destructive/5">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-destructive flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Invoice Overdue</p>
|
||||
<p className="text-sm">
|
||||
{Math.ceil(
|
||||
(new Date().getTime() - new Date(invoice.dueDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)}{" "}
|
||||
days past due date
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Client & Business */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Bill To
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<h3 className="text-foreground text-xl font-semibold">{invoice.client.name}</h3>
|
||||
<div className="space-y-3">
|
||||
{invoice.client.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm break-all">{invoice.client.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.client.phone && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Phone className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm">{invoice.client.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{(invoice.client.addressLine1 ?? invoice.client.city) && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<MapPin className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
{invoice.client.addressLine1 && <div>{invoice.client.addressLine1}</div>}
|
||||
{invoice.client.addressLine2 && <div>{invoice.client.addressLine2}</div>}
|
||||
{(invoice.client.city ??
|
||||
invoice.client.state ??
|
||||
invoice.client.postalCode) && (
|
||||
<div>
|
||||
{[
|
||||
invoice.client.city,
|
||||
invoice.client.state,
|
||||
invoice.client.postalCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{invoice.client.country && <div>{invoice.client.country}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{invoice.business && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5" />
|
||||
From
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invoice.business.logoStorageKey && (
|
||||
<div className="bg-muted border-border/40 flex h-12 max-w-40 w-fit items-center justify-center overflow-hidden border px-2 py-1.5">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
|
||||
<img
|
||||
src={`/api/business-logo/${invoice.business.id}`}
|
||||
alt={`${invoice.business.name} logo`}
|
||||
className="h-full w-auto max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-foreground text-xl font-semibold">
|
||||
{invoice.business.name}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{invoice.business.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm break-all">{invoice.business.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.business.phone && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Phone className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm">{invoice.business.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice Items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Invoice Items
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invoice.items.map((item) => (
|
||||
<Card key={item.id} className="invoice-item bg-secondary">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground mb-2 text-base font-medium break-words">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
||||
<span className="whitespace-nowrap">
|
||||
{formatDate(item.date).replace(/ /g, " ")}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">
|
||||
{item.hours.toString()} hours
|
||||
</span>
|
||||
<span className="whitespace-nowrap">@ ${item.rate}/hr</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-primary flex-shrink-0 self-start text-lg font-semibold">
|
||||
{formatCurrency(item.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="bg-secondary rounded-lg p-4 space-y-3">
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
|
||||
<span className="text-muted-foreground">Subtotal:</span>
|
||||
<span className="font-medium">{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
{invoice.taxRate > 0 && (
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
|
||||
<span className="text-muted-foreground">Tax ({invoice.taxRate}%):</span>
|
||||
<span className="font-medium">{formatCurrency(taxAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-lg font-bold">
|
||||
<span>Total:</span>
|
||||
<span className="text-primary">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
{totalPaid > 0 && (
|
||||
<>
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-sm">
|
||||
<span className="text-muted-foreground">Paid:</span>
|
||||
<span className="text-green-600 font-medium">
|
||||
− {formatCurrency(totalPaid)}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 font-bold">
|
||||
<span>Balance Due:</span>
|
||||
<span className={balanceDue <= 0 ? "text-green-600" : "text-primary"}>
|
||||
{formatCurrency(Math.max(0, balanceDue))}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Payments */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
Payments
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRecordPaymentOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Record
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{paymentsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||
) : (payments ?? []).length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No payments recorded yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(payments ?? []).map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="bg-secondary flex items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold">{formatCurrency(p.amount)}</span>
|
||||
<Badge variant="secondary">{methodLabel(p.method)}</Badge>
|
||||
<span className="text-muted-foreground">{formatDate(p.date)}</span>
|
||||
{p.notes && (
|
||||
<span className="text-muted-foreground truncate max-w-[200px]">
|
||||
{p.notes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10 h-7 w-7 p-0 shrink-0"
|
||||
onClick={() => deletePayment.mutate({ id: p.id })}
|
||||
disabled={deletePayment.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-foreground whitespace-pre-wrap">{invoice.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||
{storedStatus === "draft" && (
|
||||
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
||||
)}
|
||||
|
||||
<Card className="lg:sticky lg:top-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Check className="h-5 w-5" />
|
||||
Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{storedStatus === "draft" ? (
|
||||
<Button asChild variant="secondary" className="w-full">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{invoice.items && invoice.client && (
|
||||
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
|
||||
)}
|
||||
|
||||
{effectiveStatus === "draft" && (
|
||||
<EnhancedSendInvoiceButton
|
||||
invoiceId={invoice.id}
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveStatus === "draft" && (
|
||||
<SendReminderEditor
|
||||
key={`${invoiceId}-${invoice.sendReminderAt?.toISOString() ?? "none"}`}
|
||||
invoiceId={invoiceId}
|
||||
savedReminderAt={invoice.sendReminderAt}
|
||||
formatDate={formatDate}
|
||||
isSaving={updateInvoice.isPending}
|
||||
onSave={(sendReminderAt) =>
|
||||
updateInvoice.mutate({
|
||||
id: invoiceId,
|
||||
sendReminderAt,
|
||||
})
|
||||
}
|
||||
onClear={() =>
|
||||
updateInvoice.mutate({ id: invoiceId, sendReminderAt: null })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
|
||||
<EnhancedSendInvoiceButton
|
||||
invoiceId={invoice.id}
|
||||
className="w-full"
|
||||
showResend={true}
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Send Reminder */}
|
||||
{canSendReminder && (
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => setReminderOpen(true)}
|
||||
>
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
Send Reminder
|
||||
</Button>
|
||||
{invoice.lastReminderSentAt && (
|
||||
<p className="text-muted-foreground mt-1 text-center text-xs">
|
||||
Last sent {daysSince(invoice.lastReminderSentAt)} day
|
||||
{daysSince(invoice.lastReminderSentAt) === 1 ? "" : "s"} ago
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share Link */}
|
||||
<Popover open={shareOpen} onOpenChange={setShareOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="secondary" className="w-full">
|
||||
<Link2 className="mr-2 h-4 w-4" />
|
||||
Share Link
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 space-y-3" align="end">
|
||||
<p className="text-sm font-semibold">Client share link</p>
|
||||
{publicUrl ? (
|
||||
<>
|
||||
<div className="bg-secondary flex items-center gap-2 rounded-md px-3 py-2">
|
||||
<p className="flex-1 truncate text-xs">{publicUrl}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 w-full"
|
||||
onClick={() => revokePublicToken.mutate({ id: invoiceId })}
|
||||
disabled={revokePublicToken.isPending}
|
||||
>
|
||||
<Link2Off className="mr-1.5 h-3.5 w-3.5" />
|
||||
Revoke link
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Generate a shareable link your client can use to view this invoice without
|
||||
logging in.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => generatePublicToken.mutate({ id: invoiceId })}
|
||||
disabled={generatePublicToken.isPending}
|
||||
>
|
||||
{generatePublicToken.isPending ? (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Link2 className="mr-2 h-3.5 w-3.5" />
|
||||
)}
|
||||
Generate link
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Mark as Paid */}
|
||||
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
|
||||
<Button
|
||||
onClick={() => updateStatus.mutate({ id: invoiceId, status: "paid" })}
|
||||
disabled={updateStatus.isPending}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
{updateStatus.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<DollarSign className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Mark as Paid
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
disabled={deleteInvoice.isPending}
|
||||
className="text-destructive hover:bg-destructive/10 w-full"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Invoice
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Record Payment Dialog */}
|
||||
<Dialog open={recordPaymentOpen} onOpenChange={setRecordPaymentOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Record Payment</DialogTitle>
|
||||
<DialogDescription>
|
||||
Record a payment received for invoice {invoice.invoiceNumber}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pay-amount">Amount</Label>
|
||||
<Input
|
||||
id="pay-amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={paymentAmount}
|
||||
onChange={(e) => setPaymentAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pay-method">Method</Label>
|
||||
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
|
||||
<SelectTrigger id="pay-method">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAYMENT_METHODS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pay-notes">Notes (optional)</Label>
|
||||
<Input
|
||||
id="pay-notes"
|
||||
placeholder="e.g. cheque #1234"
|
||||
value={paymentNotes}
|
||||
onChange={(e) => setPaymentNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRecordPaymentOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRecordPayment} disabled={createPayment.isPending}>
|
||||
{createPayment.isPending ? "Saving…" : "Record Payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Send Reminder Dialog */}
|
||||
<Dialog open={reminderOpen} onOpenChange={setReminderOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send Reminder</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send a payment reminder to {invoice.client.name} for invoice{" "}
|
||||
{invoice.invoiceNumber}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reminder-msg">Custom message (optional)</Label>
|
||||
<Textarea
|
||||
id="reminder-msg"
|
||||
placeholder="Leave blank to use the default reminder message."
|
||||
rows={4}
|
||||
value={reminderMessage}
|
||||
onChange={(e) => setReminderMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReminderOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
sendReminder.mutate({
|
||||
id: invoiceId,
|
||||
customMessage: reminderMessage || undefined,
|
||||
})
|
||||
}
|
||||
disabled={sendReminder.isPending}
|
||||
>
|
||||
{sendReminder.isPending ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sending…</>
|
||||
) : (
|
||||
<><Bell className="mr-2 h-4 w-4" /> Send Reminder</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete invoice <strong>{invoice.invoiceNumber}</strong>?
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
disabled={deleteInvoice.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteInvoice.mutate({ id: invoiceId })}
|
||||
disabled={deleteInvoice.isPending}
|
||||
>
|
||||
{deleteInvoice.isPending ? "Deleting…" : "Delete Invoice"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
function SendReminderEditor({
|
||||
invoiceId,
|
||||
savedReminderAt,
|
||||
formatDate,
|
||||
isSaving,
|
||||
onSave,
|
||||
onClear,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
savedReminderAt: Date | null | undefined;
|
||||
formatDate: (date: Date) => string;
|
||||
isSaving: boolean;
|
||||
onSave: (sendReminderAt: Date | null) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const [sendReminderAt, setSendReminderAt] = useState<Date | undefined>(() =>
|
||||
savedReminderAt ? new Date(savedReminderAt) : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<Label htmlFor={`send-reminder-at-${invoiceId}`}>Remind me to send</Label>
|
||||
<DatePicker
|
||||
date={sendReminderAt}
|
||||
onDateChange={setSendReminderAt}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => onSave(sendReminderAt ?? null)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Save reminder
|
||||
</Button>
|
||||
{sendReminderAt ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSendReminderAt(undefined);
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{savedReminderAt ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{new Date(savedReminderAt) <= new Date()
|
||||
? "Reminder is due — time to send this invoice."
|
||||
: `Scheduled for ${formatDate(savedReminderAt)}`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvoiceViewPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (id === "new") router.replace("/dashboard/invoices/new");
|
||||
}, [id, router]);
|
||||
|
||||
if (id === "new") {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <InvoiceViewContent invoiceId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
DashboardPage,
|
||||
dashboardGapClass,
|
||||
dashboardGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import {
|
||||
PageTabs,
|
||||
PageTabsContent,
|
||||
PageTabsList,
|
||||
PageTabsTrigger,
|
||||
} from "~/components/layout/page-tabs";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { EmailComposer } from "~/components/forms/email-composer";
|
||||
import { EmailPreview } from "~/components/forms/email-preview";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
Eye,
|
||||
Edit3,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
function SendEmailPageSkeleton() {
|
||||
return (
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title="Loading..."
|
||||
description="Loading invoice email"
|
||||
/>
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
<div className={cn("lg:col-span-2", dashboardGapClass, "flex flex-col")}>
|
||||
<div className="bg-muted h-96 animate-pulse" />
|
||||
</div>
|
||||
<div className={cn(dashboardGapClass, "flex flex-col")}>
|
||||
<div className="bg-muted h-64 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
function plainTextToHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function normalizeEmailNoteHtml(value: string) {
|
||||
const visibleText = value
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ |\u00a0/g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
|
||||
return visibleText ? value.trim() : "";
|
||||
}
|
||||
|
||||
export default function SendEmailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const invoiceId = params.id as string;
|
||||
|
||||
// State management
|
||||
const [activeTab, setActiveTab] = useState("compose");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
// Email content state
|
||||
const [subject, setSubject] = useState("");
|
||||
const [emailContent, setEmailContent] = useState("");
|
||||
const [ccEmail, setCcEmail] = useState("");
|
||||
const [bccEmail, setBccEmail] = useState("");
|
||||
const [customMessage, setCustomMessage] = useState("");
|
||||
|
||||
// Fetch invoice data
|
||||
const { data: invoiceData, isLoading: invoiceLoading } =
|
||||
api.invoices.getById.useQuery({
|
||||
id: invoiceId,
|
||||
});
|
||||
|
||||
// Get utils for cache invalidation
|
||||
const utils = api.useUtils();
|
||||
|
||||
// Email sending mutation
|
||||
const sendEmailMutation = api.email.sendInvoice.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success("Email sent successfully!", {
|
||||
description: data.message,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
// Navigate back to invoice view
|
||||
router.push(`/dashboard/invoices/${invoiceId}`);
|
||||
|
||||
// Refresh invoice data
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
},
|
||||
onError: (error) => {
|
||||
let errorMessage = "Failed to send invoice email";
|
||||
let errorDescription = error.message;
|
||||
let canRetry = false;
|
||||
|
||||
if (error.message.includes("Invalid recipient")) {
|
||||
errorMessage = "Invalid Email Address";
|
||||
errorDescription =
|
||||
"Please check the client's email address and try again.";
|
||||
} else if (error.message.includes("domain not verified")) {
|
||||
errorMessage = "Email Configuration Issue";
|
||||
errorDescription = "Please contact support to configure email sending.";
|
||||
} else if (error.message.includes("rate limit")) {
|
||||
errorMessage = "Too Many Emails";
|
||||
errorDescription = "Please wait a moment before sending another email.";
|
||||
canRetry = true;
|
||||
} else if (error.message.includes("no email address")) {
|
||||
errorMessage = "No Email Address";
|
||||
errorDescription = "This client doesn't have an email address on file.";
|
||||
} else if (
|
||||
error.message.includes("unavailable") ||
|
||||
error.message.includes("timeout")
|
||||
) {
|
||||
errorMessage = "Service Temporarily Unavailable";
|
||||
errorDescription =
|
||||
"The email service is temporarily unavailable. Please try again.";
|
||||
canRetry = true;
|
||||
} else {
|
||||
canRetry = true; // Allow retry for unknown errors
|
||||
}
|
||||
|
||||
toast.error(errorMessage, {
|
||||
description:
|
||||
canRetry && retryCount < 2
|
||||
? `${errorDescription} You can retry this operation.`
|
||||
: errorDescription,
|
||||
duration: 6000,
|
||||
action:
|
||||
canRetry && retryCount < 2
|
||||
? {
|
||||
label: "Retry",
|
||||
onClick: () => handleRetry(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
setIsSending(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Transform invoice data for components
|
||||
const invoice = useMemo(() => {
|
||||
return invoiceData
|
||||
? {
|
||||
id: invoiceData.id,
|
||||
invoiceNumber: invoiceData.invoiceNumber,
|
||||
issueDate: invoiceData.issueDate,
|
||||
dueDate: invoiceData.dueDate,
|
||||
status: invoiceData.status,
|
||||
totalAmount: invoiceData.totalAmount,
|
||||
taxRate: invoiceData.taxRate,
|
||||
currency: invoiceData.currency,
|
||||
emailMessage: invoiceData.emailMessage,
|
||||
client: invoiceData.client
|
||||
? {
|
||||
name: invoiceData.client.name,
|
||||
email: invoiceData.client.email,
|
||||
}
|
||||
: undefined,
|
||||
business: invoiceData.business
|
||||
? {
|
||||
id: invoiceData.business.id,
|
||||
name: invoiceData.business.name,
|
||||
nickname: invoiceData.business.nickname,
|
||||
email: invoiceData.business.email,
|
||||
logoStorageKey: invoiceData.business.logoStorageKey,
|
||||
logoMimeType: invoiceData.business.logoMimeType,
|
||||
}
|
||||
: undefined,
|
||||
items: invoiceData.items?.map((item) => ({
|
||||
id: item.id,
|
||||
date: item.date,
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
})),
|
||||
}
|
||||
: undefined;
|
||||
}, [invoiceData]);
|
||||
|
||||
const normalizedCustomMessage = useMemo(
|
||||
() => normalizeEmailNoteHtml(customMessage),
|
||||
[customMessage],
|
||||
);
|
||||
|
||||
// Initialize email content when invoice loads
|
||||
useEffect(() => {
|
||||
if (!invoice || isInitialized) return;
|
||||
|
||||
// Set default subject
|
||||
const defaultSubject = `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSubject(defaultSubject);
|
||||
|
||||
// Set default content (empty since template handles everything)
|
||||
const defaultContent = ``;
|
||||
|
||||
setEmailContent(defaultContent);
|
||||
setCustomMessage(
|
||||
invoice.emailMessage ? plainTextToHtml(invoice.emailMessage) : "",
|
||||
);
|
||||
setIsInitialized(true);
|
||||
}, [invoice, isInitialized]);
|
||||
|
||||
const handleSendEmail = async () => {
|
||||
if (!invoice?.client?.email || invoice.client.email.trim() === "") {
|
||||
toast.error("No email address", {
|
||||
description: "This client doesn't have an email address on file.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subject.trim()) {
|
||||
toast.error("Subject required", {
|
||||
description: "Please enter an email subject before sending.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const confirmSendEmail = async () => {
|
||||
setShowConfirmDialog(false);
|
||||
setIsSending(true);
|
||||
|
||||
try {
|
||||
await sendEmailMutation.mutateAsync({
|
||||
invoiceId,
|
||||
customSubject: subject,
|
||||
customContent: emailContent,
|
||||
customMessage: normalizedCustomMessage,
|
||||
useHtml: true,
|
||||
ccEmails: ccEmail.trim() || undefined,
|
||||
bccEmails: bccEmail.trim() || undefined,
|
||||
});
|
||||
setRetryCount(0); // Reset retry count on success
|
||||
} catch {
|
||||
// Error handling is done in the mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
if (retryCount < 2) {
|
||||
setRetryCount((prev) => prev + 1);
|
||||
void confirmSendEmail();
|
||||
}
|
||||
};
|
||||
|
||||
const fromEmail = invoice?.business?.email ?? NOREPLY_EMAIL;
|
||||
const toEmail = invoice?.client?.email ?? "";
|
||||
|
||||
const canSend =
|
||||
!isSending && subject.trim() && toEmail && toEmail.trim() !== "";
|
||||
|
||||
if (invoiceLoading) {
|
||||
return <SendEmailPageSkeleton />;
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
<DashboardPage>
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>Invoice not found.</AlertDescription>
|
||||
</Alert>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title={`Send Invoice ${invoice.invoiceNumber}`}
|
||||
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"} • ${new Intl.DateTimeFormat(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
},
|
||||
).format(new Date())}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/invoices/${invoiceId}`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Invoice
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
{/* Warning for missing email */}
|
||||
{(!toEmail || toEmail.trim() === "") && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
This client doesn't have an email address. Please add an email
|
||||
address to the client before sending the invoice.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
<div className="lg:col-span-2">
|
||||
<PageTabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="compose" className="gap-2">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
Compose
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="preview" className="gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
<PageTabsContent value="compose">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
Compose Email
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isInitialized ? (
|
||||
<EmailComposer
|
||||
subject={subject}
|
||||
onSubjectChange={setSubject}
|
||||
content={emailContent}
|
||||
onContentChange={setEmailContent}
|
||||
customMessage={customMessage}
|
||||
onCustomMessageChange={setCustomMessage}
|
||||
fromEmail={fromEmail}
|
||||
toEmail={toEmail}
|
||||
ccEmail={ccEmail}
|
||||
onCcEmailChange={setCcEmail}
|
||||
bccEmail={bccEmail}
|
||||
onBccEmailChange={setBccEmail}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted flex h-[400px] items-center justify-center border">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin border-2 border-t-transparent"></div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Initializing email content...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageTabsContent>
|
||||
|
||||
<PageTabsContent value="preview">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5" />
|
||||
Email Preview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<EmailPreview
|
||||
subject={subject}
|
||||
fromEmail={fromEmail}
|
||||
toEmail={toEmail}
|
||||
ccEmail={ccEmail}
|
||||
bccEmail={bccEmail}
|
||||
content={emailContent}
|
||||
customMessage={normalizedCustomMessage}
|
||||
invoice={invoice}
|
||||
className="min-w-0 border-0"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={cn(dashboardGapClass, "flex flex-col")}>
|
||||
{/* Invoice Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
Invoice #{invoice.invoiceNumber}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
Client
|
||||
</Label>
|
||||
<p className="text-sm font-medium">
|
||||
{invoice.client?.name ?? "Client"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
Issue Date
|
||||
</Label>
|
||||
<p className="text-sm">
|
||||
{new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(invoice.issueDate))}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
Status
|
||||
</Label>
|
||||
<Badge variant="outline">{invoice.status}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Email Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
From
|
||||
</Label>
|
||||
<p className="font-mono text-sm break-all">{fromEmail}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
To
|
||||
</Label>
|
||||
<p className="font-mono text-sm break-all">
|
||||
{toEmail || "No email address"}
|
||||
</p>
|
||||
</div>
|
||||
{ccEmail && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
CC
|
||||
</Label>
|
||||
<p className="font-mono text-sm break-all">{ccEmail}</p>
|
||||
</div>
|
||||
)}
|
||||
{bccEmail && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
BCC
|
||||
</Label>
|
||||
<p className="font-mono text-sm break-all">{bccEmail}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
Subject
|
||||
</Label>
|
||||
<p className="text-sm break-words">{subject || "No subject"}</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
Attachment
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>invoice-{invoice.invoiceNumber}.pdf</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{activeTab === "compose" && (
|
||||
<Button
|
||||
onClick={() => setActiveTab("preview")}
|
||||
disabled={!subject.trim()}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Preview Email
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{activeTab === "preview" && (
|
||||
<Button
|
||||
onClick={() => setActiveTab("compose")}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Edit3 className="mr-2 h-4 w-4" />
|
||||
Edit Email
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Action Bar */}
|
||||
<FloatingActionBar
|
||||
leftContent={
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Send className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-foreground font-medium">Send Invoice</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Email invoice to {invoice.client?.name ?? "client"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/dashboard/invoices/${invoiceId}`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleSendEmail}
|
||||
disabled={!canSend || isSending}
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
||||
<span className="hidden sm:inline">Sending...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Send Email</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</FloatingActionBar>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send this invoice email to <strong>{toEmail}</strong>
|
||||
{ccEmail && (
|
||||
<>
|
||||
{" "}
|
||||
with CC to <strong>{ccEmail}</strong>
|
||||
</>
|
||||
)}
|
||||
{bccEmail && (
|
||||
<>
|
||||
{" "}
|
||||
and BCC to <strong>{bccEmail}</strong>
|
||||
</>
|
||||
)}
|
||||
?
|
||||
</DialogDescription>
|
||||
{retryCount > 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Retry attempt {retryCount} of 2
|
||||
</p>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div className="bg-muted/30 space-y-2 border p-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Subject: </span>
|
||||
<span className="font-medium">{subject}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Attachment: </span>
|
||||
<span>invoice-{invoice.invoiceNumber}.pdf</span>
|
||||
</div>
|
||||
{normalizedCustomMessage && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Email note: </span>
|
||||
<span>Included</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={confirmSendEmail} variant="default">
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ColumnDef, Row } from "@tanstack/react-table";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
|
||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import {
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
Send,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import { formatCurrency } from "~/lib/currency";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
clientId: string;
|
||||
businessId: string | null;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
taxRate: number;
|
||||
currency: string;
|
||||
notes: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
client?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
} | null;
|
||||
business?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
} | null;
|
||||
items?: Array<{
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
interface InvoicesDataTableProps {
|
||||
invoices: Invoice[];
|
||||
}
|
||||
|
||||
const getStatusType = (invoice: Invoice): StatusType =>
|
||||
getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
);
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(new Date(date));
|
||||
|
||||
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [pendingBulkDelete, setPendingBulkDelete] = useState<Invoice[]>([]);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteInvoice = api.invoices.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Invoice deleted");
|
||||
void utils.invoices.getAll.invalidate();
|
||||
setDeleteDialogOpen(false);
|
||||
setInvoiceToDelete(null);
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
|
||||
});
|
||||
|
||||
const bulkDelete = api.invoices.bulkDelete.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(
|
||||
`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`,
|
||||
);
|
||||
void utils.invoices.getAll.invalidate();
|
||||
setBulkDeleteDialogOpen(false);
|
||||
setPendingBulkDelete([]);
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to delete invoices"),
|
||||
});
|
||||
|
||||
const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(
|
||||
`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`,
|
||||
);
|
||||
void utils.invoices.getAll.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to update invoices"),
|
||||
});
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
|
||||
aria-label="Select all"
|
||||
data-action-button="true"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: Row<Invoice> }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||
aria-label="Select row"
|
||||
data-action-button="true"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "client.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Client" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 hidden p-2 sm:flex">
|
||||
<FileText className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
{invoice.client?.name ?? "—"}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
||||
{invoice.invoiceNumber}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 sm:hidden">
|
||||
<StatusBadge
|
||||
status={getStatusType(invoice)}
|
||||
className="text-xs"
|
||||
/>
|
||||
<span className="text-foreground text-xs font-semibold">
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "issueDate",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Date" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">
|
||||
{formatDate(row.getValue("issueDate"))}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
Due {formatDate(new Date(row.original.dueDate))}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
status={getStatusType(row.original)}
|
||||
className={
|
||||
getStatusType(row.original) === "sent" ? "status-pending" : ""
|
||||
}
|
||||
/>
|
||||
),
|
||||
filterFn: (row, _id, value: string[]) =>
|
||||
value.includes(getStatusType(row.original)),
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "totalAmount",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Amount" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold">
|
||||
{formatCurrency(row.getValue("totalAmount"), row.original.currency)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{row.original.items?.length ?? 0} items
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover-scale h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
{invoice.status === "draft" ? (
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover-scale h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
title="Edit invoice"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover-scale h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
disabled
|
||||
title="Only draft invoices can be edited"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setInvoiceToDelete(invoice);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
data-action-button="true"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{invoice.items && invoice.client && (
|
||||
<div data-action-button="true">
|
||||
<PDFDownloadButton invoiceId={invoice.id} variant="icon" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filterableColumns = [
|
||||
{
|
||||
id: "status",
|
||||
title: "Status",
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Sent", value: "sent" },
|
||||
{ label: "Paid", value: "paid" },
|
||||
{ label: "Overdue", value: "overdue" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={invoices}
|
||||
searchKey="invoiceNumber"
|
||||
searchPlaceholder="Search invoices..."
|
||||
initialSorting={[{ id: "issueDate", desc: true }]}
|
||||
filterableColumns={filterableColumns}
|
||||
emptyTitle="Create your first invoice"
|
||||
emptyDescription="Send professional invoices and track payments from one place."
|
||||
emptyIcon={<FileText className="h-6 w-6" />}
|
||||
emptyAction={
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create invoice
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
onRowClick={(invoice) =>
|
||||
router.push(`/dashboard/invoices/${invoice.id}`)
|
||||
}
|
||||
selectionActions={(selected, clear) => (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={bulkUpdateStatus.isPending}
|
||||
>
|
||||
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||
Mark as
|
||||
<ChevronDown className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
bulkUpdateStatus.mutate(
|
||||
{ ids: selected.map((i) => i.id), status: "sent" },
|
||||
{ onSuccess: clear },
|
||||
)
|
||||
}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" /> Mark Sent
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
bulkUpdateStatus.mutate(
|
||||
{ ids: selected.map((i) => i.id), status: "paid" },
|
||||
{ onSuccess: clear },
|
||||
)
|
||||
}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" /> Mark Paid
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
bulkUpdateStatus.mutate(
|
||||
{ ids: selected.map((i) => i.id), status: "draft" },
|
||||
{ onSuccess: clear },
|
||||
)
|
||||
}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" /> Mark Draft
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={bulkDelete.isPending}
|
||||
onClick={() => {
|
||||
setPendingBulkDelete(selected);
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete ({selected.length})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Single delete dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete invoice{" "}
|
||||
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
|
||||
<strong>{invoiceToDelete?.client?.name}</strong>? This action
|
||||
cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
disabled={deleteInvoice.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
invoiceToDelete &&
|
||||
deleteInvoice.mutate({ id: invoiceToDelete.id })
|
||||
}
|
||||
disabled={deleteInvoice.isPending}
|
||||
>
|
||||
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk delete dialog */}
|
||||
<Dialog
|
||||
open={bulkDeleteDialogOpen}
|
||||
onOpenChange={setBulkDeleteDialogOpen}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Delete {pendingBulkDelete.length} Invoice
|
||||
{pendingBulkDelete.length !== 1 ? "s" : ""}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete {pendingBulkDelete.length} invoice
|
||||
{pendingBulkDelete.length !== 1 ? "s" : ""}. This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setBulkDeleteDialogOpen(false)}
|
||||
disabled={bulkDelete.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })
|
||||
}
|
||||
disabled={bulkDelete.isPending}
|
||||
>
|
||||
{bulkDelete.isPending
|
||||
? "Deleting..."
|
||||
: `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function ImportPage() {
|
||||
redirect("/dashboard/settings?tab=data");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
|
||||
import InvoiceForm from "~/components/forms/invoice-form";
|
||||
|
||||
export default function NewInvoicePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<InvoiceForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { Plus } from "lucide-react";
|
||||
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
|
||||
// Invoices Table Component
|
||||
async function InvoicesTable() {
|
||||
const invoices = await api.invoices.getAll();
|
||||
|
||||
return <InvoicesDataTable invoices={invoices} />;
|
||||
}
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Invoices"
|
||||
description="Manage your invoices and track payments"
|
||||
>
|
||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>Create Invoice</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
||||
<InvoicesTable />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Check,
|
||||
Loader2,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
const SCHEDULES = [
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "biweekly", label: "Every 2 weeks" },
|
||||
{ value: "monthly", label: "Monthly" },
|
||||
{ value: "quarterly", label: "Quarterly" },
|
||||
{ value: "yearly", label: "Yearly" },
|
||||
] as const;
|
||||
|
||||
type Schedule = (typeof SCHEDULES)[number]["value"];
|
||||
|
||||
interface RecurringItemInput {
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
interface RecurringFormState {
|
||||
name: string;
|
||||
clientId: string;
|
||||
businessId: string;
|
||||
schedule: Schedule;
|
||||
invoicePrefix: string;
|
||||
taxRate: number;
|
||||
currency: string;
|
||||
notes: string;
|
||||
emailMessage: string;
|
||||
items: RecurringItemInput[];
|
||||
}
|
||||
|
||||
const defaultForm = (): RecurringFormState => ({
|
||||
name: "",
|
||||
clientId: "",
|
||||
businessId: "",
|
||||
schedule: "monthly",
|
||||
invoicePrefix: "#",
|
||||
taxRate: 0,
|
||||
currency: "USD",
|
||||
notes: "",
|
||||
emailMessage: "",
|
||||
items: [{ description: "", hours: 0, rate: 0 }],
|
||||
});
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
}
|
||||
|
||||
function scheduleLabel(s: string) {
|
||||
return SCHEDULES.find((x) => x.value === s)?.label ?? s;
|
||||
}
|
||||
|
||||
function RecurringForm({
|
||||
form,
|
||||
setForm,
|
||||
clients,
|
||||
businesses,
|
||||
}: {
|
||||
form: RecurringFormState;
|
||||
setForm: React.Dispatch<React.SetStateAction<RecurringFormState>>;
|
||||
clients: { id: string; name: string }[];
|
||||
businesses: { id: string; name: string }[];
|
||||
}) {
|
||||
const addItem = () =>
|
||||
setForm((f) => ({ ...f, items: [...f.items, { description: "", hours: 0, rate: 0 }] }));
|
||||
|
||||
const removeItem = (idx: number) =>
|
||||
setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) }));
|
||||
|
||||
const updateItem = (idx: number, field: keyof RecurringItemInput, value: string | number) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
items: f.items.map((item, i) => (i === idx ? { ...item, [field]: value } : item)),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Template name</Label>
|
||||
<Input
|
||||
placeholder="e.g. Monthly retainer"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Client</Label>
|
||||
<Select
|
||||
value={form.clientId}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, clientId: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Business (optional)</Label>
|
||||
<Select
|
||||
value={form.businessId}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, businessId: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{businesses.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Schedule</Label>
|
||||
<Select
|
||||
value={form.schedule}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, schedule: v as Schedule }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCHEDULES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Currency</Label>
|
||||
<Input
|
||||
maxLength={3}
|
||||
placeholder="USD"
|
||||
value={form.currency}
|
||||
onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value.toUpperCase() }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tax rate (%)</Label>
|
||||
<NumberInput
|
||||
value={form.taxRate}
|
||||
onChange={(v) => setForm((f) => ({ ...f, taxRate: v ?? 0 }))}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
<div className="space-y-2">
|
||||
<Label>Line items</Label>
|
||||
{form.items.map((item, idx) => (
|
||||
<div key={idx} className="bg-secondary space-y-2 rounded-lg p-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Description"
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(idx, "description", e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
{form.items.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive h-8 w-8 p-0 shrink-0"
|
||||
onClick={() => removeItem(idx)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">Hours</Label>
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(v) => updateItem(idx, "hours", v ?? 0)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Rate ($/hr)</Label>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(v) => updateItem(idx, "rate", v ?? 0)}
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" size="sm" variant="outline" onClick={addItem}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Add item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Notes (optional)</Label>
|
||||
<Textarea
|
||||
placeholder="Notes shown on generated invoices"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecurringInvoicesPage() {
|
||||
const router = useRouter();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<RecurringFormState>(defaultForm());
|
||||
|
||||
const { data: recurring, isLoading } = api.recurringInvoices.getAll.useQuery();
|
||||
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||
const { data: businesses = [] } = api.businesses.getAll.useQuery();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const invalidate = () => void utils.recurringInvoices.getAll.invalidate();
|
||||
|
||||
const create = api.recurringInvoices.create.useMutation({
|
||||
onSuccess: () => { toast.success("Recurring invoice created"); setCreateOpen(false); setForm(defaultForm()); invalidate(); },
|
||||
onError: (e) => toast.error(e.message ?? "Failed to create"),
|
||||
});
|
||||
|
||||
const update = api.recurringInvoices.update.useMutation({
|
||||
onSuccess: () => { toast.success("Updated"); setEditId(null); setForm(defaultForm()); invalidate(); },
|
||||
onError: (e) => toast.error(e.message ?? "Failed to update"),
|
||||
});
|
||||
|
||||
const pause = api.recurringInvoices.pause.useMutation({
|
||||
onSuccess: () => { toast.success("Paused"); invalidate(); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const resume = api.recurringInvoices.resume.useMutation({
|
||||
onSuccess: () => { toast.success("Resumed"); invalidate(); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const del = api.recurringInvoices.delete.useMutation({
|
||||
onSuccess: () => { toast.success("Deleted"); setDeleteId(null); invalidate(); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const generateNow = api.recurringInvoices.generateNow.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success("Invoice generated");
|
||||
invalidate();
|
||||
router.push(`/dashboard/invoices/${data.invoiceId}`);
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to generate"),
|
||||
});
|
||||
|
||||
function handleOpenEdit(rec: NonNullable<typeof recurring>[number]) {
|
||||
setForm({
|
||||
name: rec.name,
|
||||
clientId: rec.clientId,
|
||||
businessId: rec.businessId ?? "",
|
||||
schedule: rec.schedule as Schedule,
|
||||
invoicePrefix: rec.invoicePrefix ?? "#",
|
||||
taxRate: rec.taxRate,
|
||||
currency: rec.currency,
|
||||
notes: rec.notes ?? "",
|
||||
emailMessage: rec.emailMessage ?? "",
|
||||
items: rec.items.map((i) => ({
|
||||
description: i.description,
|
||||
hours: i.hours,
|
||||
rate: i.rate,
|
||||
})),
|
||||
});
|
||||
setEditId(rec.id);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const payload = {
|
||||
...form,
|
||||
businessId: form.businessId || undefined,
|
||||
notes: form.notes || undefined,
|
||||
emailMessage: form.emailMessage || undefined,
|
||||
items: form.items.map((item, idx) => ({ ...item, position: idx })),
|
||||
};
|
||||
if (editId) {
|
||||
update.mutate({ id: editId, ...payload });
|
||||
} else {
|
||||
create.mutate(payload);
|
||||
}
|
||||
}
|
||||
|
||||
const isSubmitting = create.isPending || update.isPending;
|
||||
|
||||
return (
|
||||
<DashboardPage className="pb-24">
|
||||
<DashboardPageHeader
|
||||
title="Recurring Invoices"
|
||||
description="Schedule automatic invoice generation"
|
||||
>
|
||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New recurring
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (recurring ?? []).length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={<RefreshCw className="h-6 w-6" />}
|
||||
title="Create your first recurring invoice"
|
||||
description="Automatically generate draft invoices on a schedule you choose."
|
||||
action={
|
||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create recurring invoice
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{(recurring ?? []).map((rec) => (
|
||||
<Card key={rec.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-semibold">{rec.name}</p>
|
||||
<Badge variant={rec.status === "active" ? "default" : "secondary"}>
|
||||
{rec.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{rec.client.name} · {scheduleLabel(rec.schedule)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Next: {formatDate(rec.nextDueAt)}
|
||||
{rec.lastGeneratedAt && (
|
||||
<> · Last generated: {formatDate(rec.lastGeneratedAt)}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => generateNow.mutate({ id: rec.id })}
|
||||
disabled={generateNow.isPending}
|
||||
>
|
||||
<Zap className="mr-1.5 h-3.5 w-3.5" />
|
||||
Generate now
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenEdit(rec)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{rec.status === "active" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => pause.mutate({ id: rec.id })}
|
||||
disabled={pause.isPending}
|
||||
>
|
||||
<Pause className="mr-1.5 h-3.5 w-3.5" />
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => resume.mutate({ id: rec.id })}
|
||||
disabled={resume.isPending}
|
||||
>
|
||||
<Play className="mr-1.5 h-3.5 w-3.5" />
|
||||
Resume
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setDeleteId(rec.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create / Edit Dialog */}
|
||||
<Dialog
|
||||
open={createOpen || editId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editId ? "Edit recurring invoice" : "New recurring invoice"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure the template. Invoices will be generated as drafts on the selected schedule.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<RecurringForm
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
clients={clients}
|
||||
businesses={businesses}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || !form.name || !form.clientId}>
|
||||
{isSubmitting ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving…</>
|
||||
) : editId ? (
|
||||
<><Check className="mr-2 h-4 w-4" /> Save changes</>
|
||||
) : (
|
||||
<><Plus className="mr-2 h-4 w-4" /> Create</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Dialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete recurring invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will stop automatic generation. Already-generated invoices are not affected.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteId && del.mutate({ id: deleteId })}
|
||||
disabled={del.isPending}
|
||||
>
|
||||
{del.isPending ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { api, type RouterOutputs } from "~/trpc/react";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import {
|
||||
PageTabs,
|
||||
PageTabsContent,
|
||||
PageTabsList,
|
||||
PageTabsTrigger,
|
||||
} from "~/components/layout/page-tabs";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
|
||||
|
||||
interface TemplateForm {
|
||||
name: string;
|
||||
type: "notes" | "terms";
|
||||
content: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
const defaultForm: TemplateForm = {
|
||||
name: "",
|
||||
type: "notes",
|
||||
content: "",
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
type InvoiceTemplate = RouterOutputs["invoiceTemplates"]["getAll"][number];
|
||||
|
||||
interface TemplateListProps {
|
||||
items: InvoiceTemplate[];
|
||||
type: "notes" | "terms";
|
||||
isLoading: boolean;
|
||||
onCreate: (type: "notes" | "terms") => void;
|
||||
onEdit: (template: InvoiceTemplate) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function TemplateList({
|
||||
items,
|
||||
type,
|
||||
isLoading,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: TemplateListProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => onCreate(type)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" /> New{" "}
|
||||
{type === "notes" ? "Notes" : "Terms"} Template
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
Loading...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
No {type} templates yet.
|
||||
</div>
|
||||
) : (
|
||||
items.map((template) => (
|
||||
<Card key={template.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{template.name}</p>
|
||||
{template.isDefault && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Star className="mr-1 h-3 w-3" /> Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap">
|
||||
{template.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => onEdit(template)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive h-8 w-8 p-0"
|
||||
onClick={() => onDelete(template.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<TemplateForm>(defaultForm);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"notes" | "terms">("notes");
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: templates = [], isLoading } =
|
||||
api.invoiceTemplates.getAll.useQuery();
|
||||
|
||||
const create = api.invoiceTemplates.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Template created");
|
||||
void utils.invoiceTemplates.getAll.invalidate();
|
||||
setOpen(false);
|
||||
setForm(defaultForm);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const update = api.invoiceTemplates.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Template updated");
|
||||
void utils.invoiceTemplates.getAll.invalidate();
|
||||
setOpen(false);
|
||||
setEditId(null);
|
||||
setForm(defaultForm);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const del = api.invoiceTemplates.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Template deleted");
|
||||
void utils.invoiceTemplates.getAll.invalidate();
|
||||
setDeleteId(null);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleOpen = (type: "notes" | "terms") => {
|
||||
setEditId(null);
|
||||
setForm({ ...defaultForm, type });
|
||||
setOpen(true);
|
||||
};
|
||||
const handleEdit = (t: InvoiceTemplate) => {
|
||||
setEditId(t.id);
|
||||
setForm({
|
||||
name: t.name,
|
||||
type: t.type as "notes" | "terms",
|
||||
content: t.content,
|
||||
isDefault: t.isDefault,
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error("Name is required");
|
||||
return;
|
||||
}
|
||||
if (!form.content.trim()) {
|
||||
toast.error("Content is required");
|
||||
return;
|
||||
}
|
||||
if (editId) update.mutate({ id: editId, ...form });
|
||||
else create.mutate(form);
|
||||
};
|
||||
|
||||
const notesTemplates = templates.filter((t) => t.type === "notes");
|
||||
const termsTemplates = templates.filter((t) => t.type === "terms");
|
||||
|
||||
return (
|
||||
<DashboardPage className="pb-6">
|
||||
<DashboardPageHeader
|
||||
title="Invoice Templates"
|
||||
description="Reusable notes and payment terms for your invoices"
|
||||
/>
|
||||
|
||||
<PageTabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="notes">
|
||||
<FileText className="mr-1.5 h-4 w-4" /> Notes (
|
||||
{notesTemplates.length})
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="terms">
|
||||
<FileText className="mr-1.5 h-4 w-4" /> Terms (
|
||||
{termsTemplates.length})
|
||||
</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
<PageTabsContent value="notes">
|
||||
<TemplateList
|
||||
items={notesTemplates}
|
||||
type="notes"
|
||||
isLoading={isLoading}
|
||||
onCreate={handleOpen}
|
||||
onEdit={handleEdit}
|
||||
onDelete={setDeleteId}
|
||||
/>
|
||||
</PageTabsContent>
|
||||
<PageTabsContent value="terms">
|
||||
<TemplateList
|
||||
items={termsTemplates}
|
||||
type="terms"
|
||||
isLoading={isLoading}
|
||||
onCreate={handleOpen}
|
||||
onEdit={handleEdit}
|
||||
onDelete={setDeleteId}
|
||||
/>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
|
||||
{/* Create/Edit dialog */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editId ? "Edit Template" : "New Template"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Name *</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, name: e.target.value }))
|
||||
}
|
||||
placeholder="e.g. Standard Payment Terms"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Tabs
|
||||
value={form.type}
|
||||
onValueChange={(v) =>
|
||||
setForm((p) => ({ ...p, type: v as "notes" | "terms" }))
|
||||
}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||
<TabsTrigger value="terms">Terms</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content *</Label>
|
||||
<Textarea
|
||||
value={form.content}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, content: e.target.value }))
|
||||
}
|
||||
placeholder="Template content…"
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox
|
||||
checked={form.isDefault}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((p) => ({ ...p, isDefault: !!v }))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">Set as default for {form.type}</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={create.isPending || update.isPending}
|
||||
>
|
||||
{create.isPending || update.isPending
|
||||
? "Saving…"
|
||||
: editId
|
||||
? "Update"
|
||||
: "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete dialog */}
|
||||
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Template</DialogTitle>
|
||||
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteId && del.mutate({ id: deleteId })}
|
||||
disabled={del.isPending}
|
||||
>
|
||||
{del.isPending ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AppProviders } from "~/components/providers/app-providers";
|
||||
import { DashboardShell } from "~/components/layout/dashboard-shell";
|
||||
import { DashboardUserProvider } from "~/components/layout/dashboard-user-context";
|
||||
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getOptionalServerSessionFromHeaders();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/auth/signin?callbackUrl=/dashboard");
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, session.user.id),
|
||||
columns: {
|
||||
role: true,
|
||||
onboardingCompletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isAdmin = user?.role === "admin";
|
||||
const needsOnboarding = user?.onboardingCompletedAt == null;
|
||||
|
||||
return (
|
||||
<AppProviders>
|
||||
<DashboardUserProvider isAdmin={isAdmin} needsOnboarding={needsOnboarding}>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</DashboardUserProvider>
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { brand } from "~/lib/branding";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function OnboardingShell({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-dashboard text-foreground flex min-h-screen flex-col px-5 py-8 sm:px-6 sm:py-10">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-xl flex-1 flex-col justify-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-8 space-y-4 text-center">
|
||||
<div className="flex justify-center">
|
||||
<Logo size="lg" animated={false} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm leading-6">{brand.tagline}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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);
|
||||
}
|
||||
|
||||
const TRACK_GRID_COLUMNS = ONBOARDING_STEPS.map((_, index) =>
|
||||
index < ONBOARDING_STEPS.length - 1 ? "auto 1fr" : "auto",
|
||||
).join(" ");
|
||||
|
||||
export function OnboardingStepIndicator({ step }: { step: OnboardingStepId }) {
|
||||
const currentIndex = stepIndex(step);
|
||||
|
||||
return (
|
||||
<nav aria-label="Setup progress" className="mb-8">
|
||||
<ol className="sr-only">
|
||||
{ONBOARDING_STEPS.map((item, index) => {
|
||||
const isCurrent = currentIndex === index;
|
||||
return (
|
||||
<li key={item.id} aria-current={isCurrent ? "step" : undefined}>
|
||||
{item.label}
|
||||
{isCurrent ? " (current)" : ""}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Row 1: circles + connectors. Row 2: labels (same columns as circles). */}
|
||||
<div
|
||||
className="mx-auto grid w-full max-w-md items-center gap-y-2"
|
||||
style={{
|
||||
gridTemplateColumns: TRACK_GRID_COLUMNS,
|
||||
gridTemplateRows: "auto auto",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{ONBOARDING_STEPS.map((item, index) => {
|
||||
const isComplete = currentIndex > index;
|
||||
const isCurrent = currentIndex === index;
|
||||
const isUpcoming = currentIndex < index;
|
||||
const connectorComplete = currentIndex > index;
|
||||
const circleCol = index * 2 + 1;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="contents">
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-0.5 self-center rounded-full transition-colors",
|
||||
connectorComplete ? "bg-primary" : "bg-border/80",
|
||||
)}
|
||||
style={{ gridColumn: index * 2, gridRow: 1 }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center justify-self-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",
|
||||
)}
|
||||
style={{ gridColumn: circleCol, gridRow: 1 }}
|
||||
>
|
||||
{isComplete ? (
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"hidden min-w-0 justify-self-center text-center text-xs leading-tight font-medium sm:block",
|
||||
isCurrent ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
style={{ gridColumn: circleCol, gridRow: 2 }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Edit,
|
||||
Eye,
|
||||
FileText,
|
||||
Plus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
|
||||
import {
|
||||
InvoiceStatusChart,
|
||||
MonthlyMetricsChart,
|
||||
RevenueChart,
|
||||
} from "~/app/dashboard/_components/charts-client";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
DashboardCardTitle,
|
||||
DashboardGrid,
|
||||
DashboardPage as DashboardPageLayout,
|
||||
dashboardGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { api } from "~/trpc/server";
|
||||
import type { DashboardStats, RecentInvoice } from "./types";
|
||||
|
||||
function DashboardStats({ stats }: { stats: DashboardStats }) {
|
||||
const formatTrend = (value: number, isCount = false) => {
|
||||
if (isCount) {
|
||||
return value > 0 ? `+${value}` : value.toString();
|
||||
}
|
||||
return value > 0 ? `+${value.toFixed(1)}%` : `${value.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: "Total Revenue",
|
||||
value: `$${stats.totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||
numericValue: stats.totalRevenue,
|
||||
isCurrency: true,
|
||||
change: formatTrend(stats.revenueChange),
|
||||
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const),
|
||||
iconName: "DollarSign" as const,
|
||||
description: "Collected to date",
|
||||
},
|
||||
{
|
||||
title: "Pending",
|
||||
value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||
numericValue: stats.pendingAmount,
|
||||
isCurrency: true,
|
||||
change: "0%",
|
||||
trend: "neutral" as const,
|
||||
iconName: "Clock" as const,
|
||||
description: "Awaiting payment",
|
||||
},
|
||||
{
|
||||
title: "Clients",
|
||||
value: stats.totalClients.toString(),
|
||||
numericValue: stats.totalClients,
|
||||
isCurrency: false,
|
||||
change: "0",
|
||||
trend: "neutral" as const,
|
||||
iconName: "Users" as const,
|
||||
description: "Active clients",
|
||||
},
|
||||
{
|
||||
title: "Overdue",
|
||||
value: stats.overdueCount.toString(),
|
||||
numericValue: stats.overdueCount,
|
||||
isCurrency: false,
|
||||
change: "0",
|
||||
trend: "neutral" as const,
|
||||
iconName: "TrendingDown" as const,
|
||||
description: "Past due date",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn(dashboardGridClass, "sm:grid-cols-2 xl:grid-cols-4")}>
|
||||
{statCards.map((stat, index) => (
|
||||
<AnimatedStatsCard
|
||||
key={stat.title}
|
||||
title={stat.title}
|
||||
value={stat.value}
|
||||
numericValue={stat.numericValue}
|
||||
isCurrency={stat.isCurrency}
|
||||
iconName={stat.iconName}
|
||||
change={stat.change}
|
||||
trend={stat.trend}
|
||||
description={stat.description}
|
||||
delay={index * 100}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartsSection({ stats }: { stats: DashboardStats }) {
|
||||
return (
|
||||
<DashboardGrid className="lg:grid-cols-2">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={BarChart3}>
|
||||
Revenue over time
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RevenueChart data={stats.revenueChartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Activity}>
|
||||
Invoice status
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InvoiceStatusChart data={stats.statusChartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Calendar}>
|
||||
Monthly metrics
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MonthlyMetricsChart data={stats.monthlyMetricsChartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardGrid>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickActions() {
|
||||
const actions = [
|
||||
{
|
||||
title: "Create invoice",
|
||||
description: "Start a new invoice for a client",
|
||||
href: "/dashboard/invoices/new",
|
||||
icon: FileText,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
title: "Add client",
|
||||
description: "Register someone you bill",
|
||||
href: "/dashboard/clients/new",
|
||||
icon: Users,
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
title: "View invoices",
|
||||
description: "Browse your full pipeline",
|
||||
href: "/dashboard/invoices",
|
||||
icon: BarChart3,
|
||||
featured: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Plus}>Quick actions</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Link
|
||||
key={action.title}
|
||||
href={action.href}
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-2xl border p-4 transition-colors",
|
||||
action.featured
|
||||
? "border-primary/20 bg-primary/5 hover:bg-primary/10"
|
||||
: "border-border/60 bg-background/50 hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl p-2",
|
||||
action.featured
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{action.title}</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentWork({
|
||||
currentDraft,
|
||||
}: {
|
||||
currentDraft: DashboardStats["currentDraft"];
|
||||
}) {
|
||||
if (!currentDraft) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Activity}>
|
||||
Current work
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center py-8 text-center">
|
||||
<div className="bg-muted mb-4 rounded-2xl p-3">
|
||||
<FileText className="text-muted-foreground h-6 w-6" />
|
||||
</div>
|
||||
<p className="font-medium">No draft in progress</p>
|
||||
<CardDescription className="mt-1 max-w-xs">
|
||||
Start an invoice when you're ready to bill your next piece of
|
||||
work.
|
||||
</CardDescription>
|
||||
<Button asChild variant="outline" className="mt-5">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create invoice
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const totalHours = currentDraft.totalHours;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Activity}>Current work</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
<Badge variant="secondary">Draft</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="font-medium">#{currentDraft.invoiceNumber}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{currentDraft.client?.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-mono text-xl font-semibold tabular-nums">
|
||||
${currentDraft.totalAmount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||
{totalHours.toFixed(1)} hours logged
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentDraft.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" className="flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentDraft.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Continue
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentActivity({
|
||||
recentInvoices,
|
||||
}: {
|
||||
recentInvoices: RecentInvoice[];
|
||||
}) {
|
||||
const getStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return "default" as const;
|
||||
case "sent":
|
||||
return "secondary" as const;
|
||||
case "overdue":
|
||||
return "destructive" as const;
|
||||
default:
|
||||
return "outline" as const;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>
|
||||
<DashboardCardTitle icon={Calendar}>
|
||||
Recent activity
|
||||
</DashboardCardTitle>
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/dashboard/invoices">
|
||||
<span className="hidden sm:inline">View all</span>
|
||||
<ArrowUpRight className="h-4 w-4 sm:ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentInvoices.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<div className="bg-muted mb-4 rounded-2xl p-3">
|
||||
<FileText className="text-muted-foreground h-6 w-6" />
|
||||
</div>
|
||||
<p className="font-medium">No invoices yet</p>
|
||||
<CardDescription className="mt-1 max-w-xs">
|
||||
Your latest invoices will show up here.
|
||||
</CardDescription>
|
||||
<Button asChild variant="outline" className="mt-5">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create invoice
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentInvoices.map((invoice) => (
|
||||
<Link
|
||||
key={invoice.id}
|
||||
href={`/dashboard/invoices/${invoice.id}`}
|
||||
className="hover:bg-muted/50 border-border/60 flex items-center gap-3 rounded-2xl border p-3 transition-colors"
|
||||
>
|
||||
<div className="bg-muted rounded-xl p-2">
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-sm font-medium">
|
||||
#{invoice.invoiceNumber}
|
||||
</p>
|
||||
<span className="shrink-0 font-mono text-sm font-medium tabular-nums">
|
||||
${invoice.totalAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{invoice.client?.name}
|
||||
</p>
|
||||
<Badge
|
||||
variant={getStatusVariant(invoice.status)}
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getOptionalServerSessionFromHeaders();
|
||||
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
||||
const stats = await api.dashboard.getStats();
|
||||
|
||||
return (
|
||||
<DashboardPageLayout>
|
||||
<DashboardPageHeader
|
||||
title={`Welcome back, ${firstName}`}
|
||||
description="A snapshot of your invoices, revenue, and work in progress."
|
||||
/>
|
||||
|
||||
<DashboardStats stats={stats} />
|
||||
<ChartsSection stats={stats} />
|
||||
<DashboardGrid className="lg:grid-cols-2">
|
||||
<div className={cn(dashboardGridClass)}>
|
||||
<CurrentWork currentDraft={stats.currentDraft} />
|
||||
<QuickActions />
|
||||
</div>
|
||||
<RecentActivity recentInvoices={stats.recentInvoices} />
|
||||
</DashboardGrid>
|
||||
</DashboardPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { StatusBadge } from "~/components/data/status-badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import {
|
||||
PageTabs,
|
||||
PageTabsContent,
|
||||
PageTabsList,
|
||||
PageTabsTrigger,
|
||||
} from "~/components/layout/page-tabs";
|
||||
import { formatCurrency } from "~/lib/currency";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import {
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
Clock,
|
||||
Users,
|
||||
Download,
|
||||
Receipt,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
function toNumericChartValue(value: unknown) {
|
||||
const numericValue = typeof value === "number" ? value : Number(value ?? 0);
|
||||
return Number.isFinite(numericValue) ? numericValue : 0;
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [businessFilter, setBusinessFilter] = useState("all");
|
||||
const { data: businesses = [] } = api.businesses.getAll.useQuery();
|
||||
const { data: invoices = [], isLoading: invoicesLoading } =
|
||||
api.invoices.getAll.useQuery();
|
||||
const { data: expenses = [], isLoading: expensesLoading } =
|
||||
api.expenses.getAll.useQuery(
|
||||
businessFilter === "all" ? undefined : { businessId: businessFilter },
|
||||
);
|
||||
const { data: stats } = api.dashboard.getStats.useQuery();
|
||||
|
||||
const isLoading = invoicesLoading || expensesLoading;
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [taxYear, setTaxYear] = useState(String(currentYear));
|
||||
|
||||
const filteredInvoices = useMemo(() => {
|
||||
if (businessFilter === "all") return invoices;
|
||||
return invoices.filter((inv) => inv.businessId === businessFilter);
|
||||
}, [invoices, businessFilter]);
|
||||
|
||||
// Overview data (last 12 months)
|
||||
const overviewData = useMemo(() => {
|
||||
if (!filteredInvoices.length) return null;
|
||||
|
||||
const now = new Date();
|
||||
const monthMap: Record<string, number> = {};
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
monthMap[key] = 0;
|
||||
}
|
||||
|
||||
let totalRevenue = 0;
|
||||
let totalPending = 0;
|
||||
let totalHours = 0;
|
||||
|
||||
for (const inv of filteredInvoices) {
|
||||
const status = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
);
|
||||
if (status === "paid") {
|
||||
totalRevenue += inv.totalAmount;
|
||||
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
|
||||
if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
|
||||
} else if (status === "sent" || status === "overdue") {
|
||||
totalPending += inv.totalAmount;
|
||||
}
|
||||
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
|
||||
}
|
||||
|
||||
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
|
||||
month: new Date(month + "-01").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}),
|
||||
revenue,
|
||||
}));
|
||||
|
||||
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
||||
for (const inv of filteredInvoices) {
|
||||
const status = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
);
|
||||
if (status === "paid" && inv.client) {
|
||||
const id = inv.client.id;
|
||||
const entry = (clientMap[id] ??= {
|
||||
name: inv.client.name,
|
||||
revenue: 0,
|
||||
});
|
||||
entry.revenue += inv.totalAmount;
|
||||
}
|
||||
}
|
||||
const topClients = Object.values(clientMap)
|
||||
.sort((a, b) => b.revenue - a.revenue)
|
||||
.slice(0, 6);
|
||||
|
||||
const statusCount: Record<string, number> = {
|
||||
draft: 0,
|
||||
sent: 0,
|
||||
paid: 0,
|
||||
overdue: 0,
|
||||
};
|
||||
for (const inv of filteredInvoices) {
|
||||
const s = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
);
|
||||
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
revenueByMonth,
|
||||
topClients,
|
||||
totalRevenue,
|
||||
totalPending,
|
||||
totalHours,
|
||||
statusCount,
|
||||
};
|
||||
}, [filteredInvoices]);
|
||||
|
||||
// Tax summary for selected year
|
||||
const taxData = useMemo(() => {
|
||||
const year = parseInt(taxYear);
|
||||
|
||||
const yearInvoices = filteredInvoices.filter((inv) => {
|
||||
const status = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
);
|
||||
return (
|
||||
status === "paid" && new Date(inv.issueDate).getFullYear() === year
|
||||
);
|
||||
});
|
||||
const yearExpenses = expenses.filter(
|
||||
(exp) => new Date(exp.date).getFullYear() === year,
|
||||
);
|
||||
|
||||
const getSubtotal = (inv: (typeof yearInvoices)[number]) => {
|
||||
const itemSubtotal = (inv.items ?? []).reduce(
|
||||
(s, item) => s + item.amount,
|
||||
0,
|
||||
);
|
||||
if (itemSubtotal > 0) return itemSubtotal;
|
||||
|
||||
const taxMultiplier = 1 + (inv.taxRate ?? 0) / 100;
|
||||
return taxMultiplier > 0
|
||||
? inv.totalAmount / taxMultiplier
|
||||
: inv.totalAmount;
|
||||
};
|
||||
|
||||
const grossIncome = yearInvoices.reduce(
|
||||
(s, inv) => s + getSubtotal(inv),
|
||||
0,
|
||||
);
|
||||
const taxCollected = yearInvoices.reduce(
|
||||
(s, inv) => s + (inv.totalAmount - getSubtotal(inv)),
|
||||
0,
|
||||
);
|
||||
const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0);
|
||||
const deductibleExpenses = yearExpenses
|
||||
.filter(
|
||||
(exp) =>
|
||||
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible,
|
||||
)
|
||||
.reduce((s, exp) => s + exp.amount, 0);
|
||||
|
||||
const netProfit = grossIncome - deductibleExpenses;
|
||||
const seTaxBase = Math.max(0, netProfit) * 0.9235;
|
||||
const selfEmploymentTax = seTaxBase * 0.153;
|
||||
const taxableIncome = Math.max(0, netProfit - selfEmploymentTax / 2);
|
||||
const federalEstimate = taxableIncome * 0.22;
|
||||
const totalEstimated = selfEmploymentTax + federalEstimate;
|
||||
|
||||
const quarters = [1, 2, 3, 4].map((q) => {
|
||||
const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2];
|
||||
return {
|
||||
label: `Q${q}`,
|
||||
income: yearInvoices
|
||||
.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth()))
|
||||
.reduce((s, inv) => s + getSubtotal(inv), 0),
|
||||
expenses: yearExpenses
|
||||
.filter((exp) => qMonths.includes(new Date(exp.date).getMonth()))
|
||||
.reduce((s, exp) => s + exp.amount, 0),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
grossIncome,
|
||||
taxCollected,
|
||||
totalInvoiced: grossIncome + taxCollected,
|
||||
totalExpenses,
|
||||
deductibleExpenses,
|
||||
netProfit,
|
||||
selfEmploymentTax,
|
||||
federalEstimate,
|
||||
totalEstimated,
|
||||
quarters,
|
||||
yearInvoices,
|
||||
yearExpenses,
|
||||
};
|
||||
}, [filteredInvoices, expenses, taxYear]);
|
||||
|
||||
const availableYears = useMemo(() => {
|
||||
const years = new Set<number>([currentYear, currentYear - 1]);
|
||||
for (const inv of filteredInvoices)
|
||||
years.add(new Date(inv.issueDate).getFullYear());
|
||||
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
||||
return Array.from(years).sort((a, b) => b - a);
|
||||
}, [filteredInvoices, expenses, currentYear]);
|
||||
|
||||
const avgInvoice =
|
||||
filteredInvoices.length > 0
|
||||
? (overviewData?.totalRevenue ?? 0) /
|
||||
(filteredInvoices.filter(
|
||||
(i) =>
|
||||
getEffectiveInvoiceStatus(
|
||||
i.status as StoredInvoiceStatus,
|
||||
i.dueDate,
|
||||
) === "paid",
|
||||
).length || 1)
|
||||
: 0;
|
||||
|
||||
function exportCSV() {
|
||||
const rows: string[] = [
|
||||
`Tax Year ${taxYear} - Income & Expense Report`,
|
||||
`Generated: ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`,
|
||||
"",
|
||||
"INCOME (Paid Invoices)",
|
||||
"Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total",
|
||||
...taxData.yearInvoices.map((inv) => {
|
||||
const subtotal = (inv.items ?? []).reduce(
|
||||
(s, item) => s + item.amount,
|
||||
0,
|
||||
);
|
||||
const fallbackSubtotal =
|
||||
inv.totalAmount / (1 + (inv.taxRate ?? 0) / 100);
|
||||
const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal;
|
||||
const taxAmt = inv.totalAmount - invoiceSubtotal;
|
||||
return [
|
||||
new Date(inv.issueDate).toLocaleDateString("en-US"),
|
||||
inv.invoiceNumber,
|
||||
`"${inv.client?.name ?? ""}"`,
|
||||
invoiceSubtotal.toFixed(2),
|
||||
`${(inv.taxRate ?? 0).toFixed(1)}%`,
|
||||
taxAmt.toFixed(2),
|
||||
inv.totalAmount.toFixed(2),
|
||||
].join(",");
|
||||
}),
|
||||
`,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`,
|
||||
"",
|
||||
"EXPENSES",
|
||||
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
|
||||
...taxData.yearExpenses.map((exp) =>
|
||||
[
|
||||
new Date(exp.date).toLocaleDateString("en-US"),
|
||||
`"${exp.description}"`,
|
||||
`"${exp.category ?? ""}"`,
|
||||
exp.amount.toFixed(2),
|
||||
exp.currency,
|
||||
exp.billable ? "Yes" : "No",
|
||||
exp.reimbursable ? "Yes" : "No",
|
||||
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible
|
||||
? "Yes"
|
||||
: "No",
|
||||
].join(","),
|
||||
),
|
||||
`,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`,
|
||||
"",
|
||||
"TAX SUMMARY",
|
||||
`Gross Income,${taxData.grossIncome.toFixed(2)}`,
|
||||
`Tax Collected,${taxData.taxCollected.toFixed(2)}`,
|
||||
`Deductible Expenses,${taxData.deductibleExpenses.toFixed(2)}`,
|
||||
`Net Profit,${taxData.netProfit.toFixed(2)}`,
|
||||
`Est. Self-Employment Tax (15.3%),${taxData.selfEmploymentTax.toFixed(2)}`,
|
||||
`Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`,
|
||||
`Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`,
|
||||
];
|
||||
const blob = new Blob([rows.join("\n")], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `tax-report-${taxYear}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Reports"
|
||||
description="Revenue and tax analytics"
|
||||
/>
|
||||
<div className={dashboardStatGridClass}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Reports"
|
||||
description="Revenue and tax analytics"
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<span className="text-sm font-medium">Business</span>
|
||||
<Select value={businessFilter} onValueChange={setBusinessFilter}>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue placeholder="All businesses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All businesses</SelectItem>
|
||||
{businesses.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<PageTabs defaultValue="overview">
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="overview" className="gap-1.5">
|
||||
<TrendingUp className="h-4 w-4" /> Overview
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="tax" className="gap-1.5">
|
||||
<FileText className="h-4 w-4" /> Tax Summary
|
||||
</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
{/* ── OVERVIEW TAB ── */}
|
||||
<PageTabsContent value="overview">
|
||||
<div className={dashboardStatGridClass}>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 rounded p-1.5">
|
||||
<DollarSign className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
Total Revenue
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">
|
||||
{formatCurrency(overviewData?.totalRevenue ?? 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded bg-yellow-500/10 p-1.5">
|
||||
<Clock className="h-4 w-4 text-yellow-500" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
Pending
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">
|
||||
{formatCurrency(overviewData?.totalPending ?? 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded bg-blue-500/10 p-1.5">
|
||||
<TrendingUp className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
Avg Invoice
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">
|
||||
{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded bg-green-500/10 p-1.5">
|
||||
<Users className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
Total Hours
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">
|
||||
{(overviewData?.totalHours ?? 0).toFixed(1)}h
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-48 w-full md:h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={overviewData?.revenueByMonth ?? []}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="revenueGrad"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(142, 76%, 36%)"
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(142, 76%, 36%)"
|
||||
stopOpacity={0.02}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-border"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={{
|
||||
fontSize: 11,
|
||||
fill: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{
|
||||
fontSize: 11,
|
||||
fill: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v: number) =>
|
||||
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [
|
||||
formatCurrency(toNumericChartValue(value)),
|
||||
"Revenue",
|
||||
]}
|
||||
contentStyle={{
|
||||
background: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="hsl(142, 76%, 36%)"
|
||||
fill="url(#revenueGrad)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" /> Top Clients by Revenue
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!overviewData?.topClients.length ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
No paid invoices yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="h-48 md:h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={overviewData.topClients}
|
||||
layout="vertical"
|
||||
>
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{
|
||||
fontSize: 11,
|
||||
fill: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v: number) =>
|
||||
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={{
|
||||
fontSize: 11,
|
||||
fill: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [
|
||||
formatCurrency(toNumericChartValue(value)),
|
||||
"Revenue",
|
||||
]}
|
||||
contentStyle={{
|
||||
background: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="revenue"
|
||||
fill="hsl(142, 76%, 36%)"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invoice Status Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Object.entries(overviewData?.statusCount ?? {}).map(
|
||||
([status, count]) => (
|
||||
<div
|
||||
key={status}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<StatusBadge status={status as never} />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full"
|
||||
style={{
|
||||
width: `${filteredInvoices.length ? (count / filteredInvoices.length) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground w-8 text-right text-sm">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{filteredInvoices.length === 0 && (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
No invoices yet.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="divide-y">
|
||||
{stats.recentInvoices.map((inv) => (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="flex items-center justify-between py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{new Date(inv.issueDate).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge
|
||||
status={
|
||||
getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
) as never
|
||||
}
|
||||
/>
|
||||
<p className="font-semibold">
|
||||
{formatCurrency(inv.totalAmount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</PageTabsContent>
|
||||
|
||||
{/* ── TAX SUMMARY TAB ── */}
|
||||
<PageTabsContent value="tax">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">Tax Year</span>
|
||||
<Select value={taxYear} onValueChange={setTaxYear}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableYears.map((y) => (
|
||||
<SelectItem key={y} value={String(y)}>
|
||||
{y}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="outline" onClick={exportCSV} className="gap-2">
|
||||
<Download className="h-4 w-4" /> Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Income */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5" /> Income
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Gross Income (paid invoices)
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(taxData.grossIncome)}
|
||||
</span>
|
||||
</div>
|
||||
{taxData.taxCollected > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Tax Collected from Clients
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(taxData.taxCollected)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex justify-between font-medium">
|
||||
<span>Total Invoiced (inc. tax)</span>
|
||||
<span>{formatCurrency(taxData.totalInvoiced)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Expenses */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Receipt className="h-5 w-5" /> Expenses & Deductions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total Expenses</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(taxData.totalExpenses)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Tax-Deductible Expenses
|
||||
</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatCurrency(taxData.deductibleExpenses)}
|
||||
</span>
|
||||
</div>
|
||||
{taxData.totalExpenses > 0 &&
|
||||
taxData.deductibleExpenses === 0 && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Mark expenses as "Tax Deductible" in the Expenses
|
||||
page to include them here.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Estimated tax */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" /> Estimated Tax Liability
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Net Profit (income − deductible expenses)
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(taxData.netProfit)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Self-Employment Tax (15.3% on 92.35% of net)
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(taxData.selfEmploymentTax)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Federal Income Tax (est. 22% bracket)
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(taxData.federalEstimate)}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total Estimated Tax</span>
|
||||
<span className="text-destructive">
|
||||
{formatCurrency(taxData.totalEstimated)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground pt-1 text-xs">
|
||||
Assumes US self-employment tax rules and the 22% federal
|
||||
bracket. Consult a tax professional for accurate filing.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quarterly chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quarterly Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-48 md:h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={taxData.quarters}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-border"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{
|
||||
fontSize: 11,
|
||||
fill: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{
|
||||
fontSize: 11,
|
||||
fill: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v: number) =>
|
||||
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value, name) => [
|
||||
formatCurrency(toNumericChartValue(value)),
|
||||
name === "income" ? "Income" : "Expenses",
|
||||
]}
|
||||
contentStyle={{
|
||||
background: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="income"
|
||||
name="income"
|
||||
fill="hsl(142, 76%, 36%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="expenses"
|
||||
name="expenses"
|
||||
fill="hsl(0, 84%, 60%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
opacity={0.75}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-2 flex justify-center gap-6 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" />{" "}
|
||||
Income
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" />{" "}
|
||||
Expenses
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { Copy, Key, Plus, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
function formatApiKeyDate(value: Date | string | null) {
|
||||
if (!value) return "Never";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
async function copyText(value: string, label: string) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(`${label} copied`);
|
||||
}
|
||||
|
||||
export function ApiAccessSettings() {
|
||||
const utils = api.useUtils();
|
||||
const [keyName, setKeyName] = React.useState("");
|
||||
const [createdKey, setCreatedKey] = React.useState<string | null>(null);
|
||||
const endpoint =
|
||||
typeof window === "undefined" ? "/api/mcp" : `${window.location.origin}/api/mcp`;
|
||||
|
||||
const { data: apiKeys = [], isLoading } = api.apiKeys.list.useQuery();
|
||||
|
||||
const createApiKey = api.apiKeys.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setCreatedKey(result.key);
|
||||
setKeyName("");
|
||||
toast.success("API key created");
|
||||
void utils.apiKeys.list.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to create API key");
|
||||
},
|
||||
});
|
||||
|
||||
const revokeApiKey = api.apiKeys.revoke.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("API key revoked");
|
||||
void utils.apiKeys.list.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to revoke API key");
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateKey = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!keyName.trim()) {
|
||||
toast.error("Enter a key name");
|
||||
return;
|
||||
}
|
||||
createApiKey.mutate({ name: keyName.trim() });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card className="form-section bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<Key className="text-primary h-5 w-5" />
|
||||
API Access
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage API keys for MCP clients and direct tRPC access
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<form onSubmit={handleCreateKey} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key-name">Key Name</Label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
id="api-key-name"
|
||||
value={keyName}
|
||||
onChange={(event) => setKeyName(event.target.value)}
|
||||
placeholder="Claude Desktop"
|
||||
maxLength={100}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createApiKey.isPending}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{createApiKey.isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mcp-endpoint">MCP Endpoint</Label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input id="mcp-endpoint" value={endpoint} readOnly />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void copyText(endpoint, "Endpoint")}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createdKey && (
|
||||
<div className="border-primary/30 bg-primary/5 space-y-3 border p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium">New API key</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This key is shown once.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">Bearer</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input value={createdKey} readOnly className="font-mono text-sm" />
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void copyText(createdKey, "API key")}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-medium">Active Keys</h3>
|
||||
<Badge variant="secondary">{apiKeys.length}</Badge>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground border p-4 text-sm">
|
||||
Loading keys...
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-muted-foreground border p-4 text-sm">
|
||||
No API keys created.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-border border">
|
||||
{apiKeys.map((apiKey) => {
|
||||
const revoked = Boolean(apiKey.revokedAt);
|
||||
return (
|
||||
<div
|
||||
key={apiKey.id}
|
||||
className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium break-words">
|
||||
{apiKey.name}
|
||||
</p>
|
||||
<Badge variant={revoked ? "destructive" : "outline"}>
|
||||
{revoked ? "Revoked" : apiKey.keyPrefix}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Created {formatApiKeyDate(apiKey.createdAt)} · Last
|
||||
used {formatApiKeyDate(apiKey.lastUsedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={revoked || revokeApiKey.isPending}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Revoke
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke API key?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will immediately block requests using{" "}
|
||||
<span className="font-medium">{apiKey.name}</span>.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
revokeApiKey.mutate({ id: apiKey.id })
|
||||
}
|
||||
>
|
||||
Revoke Key
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { CircleHelp, FileJson, FileSpreadsheet, FileText } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ImportCsvTemplateButton,
|
||||
ImportJsonTemplateButton,
|
||||
} from "./import-sample-download";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
PageTabs,
|
||||
PageTabsContent,
|
||||
PageTabsList,
|
||||
PageTabsTrigger,
|
||||
} from "~/components/layout/page-tabs";
|
||||
import { JSON_TEMPLATE } from "~/lib/invoice-import-templates";
|
||||
|
||||
const CSV_COLUMNS = [
|
||||
{
|
||||
field: "date",
|
||||
required: false,
|
||||
desc: "Work date (M/D/YY, YYYY-MM-DD, or ISO)",
|
||||
},
|
||||
{
|
||||
field: "item",
|
||||
required: false,
|
||||
desc: "Short item name (combined with description if both present)",
|
||||
},
|
||||
{
|
||||
field: "description",
|
||||
required: "one of item/description",
|
||||
desc: "Line item description",
|
||||
},
|
||||
{
|
||||
field: "quantity",
|
||||
required: true,
|
||||
desc: "Hours or units (aliases: hours, qty)",
|
||||
},
|
||||
{
|
||||
field: "rate",
|
||||
required: true,
|
||||
desc: "Unit rate (aliases: price, hourly rate)",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function ImportFormatInfoDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||
<CircleHelp className="mr-2 h-4 w-4" />
|
||||
Format guide
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] flex-col sm:max-w-4xl">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
Import format guide
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
CSV and JSON reference for bulk invoice imports. All imported
|
||||
invoices are created as drafts for review.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<PageTabs defaultValue="csv" className="min-h-0 flex-1">
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="csv">
|
||||
<FileSpreadsheet className="mr-1.5 h-4 w-4" />
|
||||
CSV
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="json">
|
||||
<FileJson className="mr-1.5 h-4 w-4" />
|
||||
JSON
|
||||
</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
<PageTabsContent
|
||||
value="csv"
|
||||
className="max-h-[min(60vh,32rem)] overflow-y-auto pr-1"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
One CSV file creates one invoice. The invoice title is the
|
||||
filename without the extension (e.g.{" "}
|
||||
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
|
||||
acme-january.csv
|
||||
</code>{" "}
|
||||
→ title "acme-january"). Column headers are flexible
|
||||
and auto-detected from the .csv extension.
|
||||
</p>
|
||||
|
||||
<div className="bg-muted border-border rounded-md border p-3">
|
||||
<p className="text-foreground font-mono text-sm">
|
||||
date,description,quantity,rate
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">
|
||||
Columns (header row required)
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{CSV_COLUMNS.map((col) => (
|
||||
<div key={col.field} className="flex items-start gap-3">
|
||||
<Badge className="border font-mono text-xs">
|
||||
{col.field}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{col.desc}
|
||||
{col.required === true && " — required"}
|
||||
{typeof col.required === "string" &&
|
||||
` — ${col.required} required`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Example rows</h4>
|
||||
<div className="bg-muted border-border space-y-2 rounded-md border p-3">
|
||||
<p className="text-foreground font-mono text-xs break-all">
|
||||
2024-01-15,"API development",8,125.00
|
||||
</p>
|
||||
<p className="text-foreground font-mono text-xs break-all">
|
||||
1/16/24,Design review,2,125.00
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Rules</h4>
|
||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||
<li>
|
||||
• Column names are case-insensitive. Legacy columns{" "}
|
||||
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
|
||||
HOURS
|
||||
</code>{" "}
|
||||
and{" "}
|
||||
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
|
||||
DATE
|
||||
</code>{" "}
|
||||
are still supported.
|
||||
</li>
|
||||
<li>
|
||||
• Select a default client in Settings → Data before uploading
|
||||
CSV files.
|
||||
</li>
|
||||
<li>
|
||||
• Each line item needs a description (or item), quantity, and
|
||||
rate.
|
||||
</li>
|
||||
<li>• Max 10 MB per file, up to 50 files at once.</li>
|
||||
<li>
|
||||
• Preview staged invoices and fix per-row errors before you
|
||||
commit the import.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ImportCsvTemplateButton />
|
||||
</PageTabsContent>
|
||||
|
||||
<PageTabsContent
|
||||
value="json"
|
||||
className="max-h-[min(60vh,32rem)] overflow-y-auto pr-1"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Import one or many invoices from a single JSON file. Clients are
|
||||
matched by email, then name, or created automatically when
|
||||
details are provided.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Example</h4>
|
||||
<div className="bg-muted border-border max-h-64 overflow-auto rounded-md border p-3">
|
||||
<pre className="text-foreground font-mono text-xs whitespace-pre-wrap">
|
||||
{JSON_TEMPLATE}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Rules</h4>
|
||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||
<li>
|
||||
• Root may be{" "}
|
||||
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
|
||||
{"{ invoices: [...] }"}
|
||||
</code>
|
||||
, an array, or a single invoice object.
|
||||
</li>
|
||||
<li>
|
||||
• Line items use{" "}
|
||||
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
|
||||
quantity
|
||||
</code>{" "}
|
||||
or{" "}
|
||||
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
|
||||
hours
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
• Issue and due dates default from item dates (+30 days for
|
||||
due).
|
||||
</li>
|
||||
<li>
|
||||
• New clients are created when JSON includes unknown client
|
||||
details.
|
||||
</li>
|
||||
<li>• Max 10 MB per file, up to 50 files at once.</li>
|
||||
<li>
|
||||
• Partial success: valid invoices import; errors are reported
|
||||
per row.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ImportJsonTemplateButton />
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ImportFormatInfoDialog } from "./import-format-info-dialog";
|
||||
|
||||
export function ImportPageHeaderActions() {
|
||||
return <ImportFormatInfoDialog />;
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { FileJson, FileSpreadsheet } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
downloadCsvTemplate,
|
||||
downloadJsonTemplate,
|
||||
} from "~/lib/invoice-import-templates";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function ImportCsvTemplateButton({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn("hover-lift shadow-sm", className)}
|
||||
onClick={downloadCsvTemplate}
|
||||
>
|
||||
<FileSpreadsheet className="mr-2 h-5 w-5" />
|
||||
Download CSV template
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportJsonTemplateButton({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn("hover-lift shadow-sm", className)}
|
||||
onClick={downloadJsonTemplate}
|
||||
>
|
||||
<FileJson className="mr-2 h-5 w-5" />
|
||||
Download JSON template
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportTemplateButtons({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ImportCsvTemplateButton />
|
||||
<ImportJsonTemplateButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { BlobProvider } from "@react-pdf/renderer";
|
||||
import {
|
||||
InvoicePDF,
|
||||
type InvoiceData,
|
||||
type PDFGenerationSettings,
|
||||
} from "~/lib/pdf-export";
|
||||
|
||||
const previewInvoice: InvoiceData = {
|
||||
invoiceNumber: "BV-2026-001",
|
||||
issueDate: new Date("2026-04-30T12:00:00.000Z"),
|
||||
dueDate: new Date("2026-05-30T12:00:00.000Z"),
|
||||
status: "sent",
|
||||
totalAmount: 3150,
|
||||
taxRate: 0,
|
||||
currency: "USD",
|
||||
notes: "Thank you for the work. Payment is due within 30 days.",
|
||||
business: {
|
||||
name: "Sample Studio",
|
||||
email: "hello@beenvoice.test",
|
||||
phone: "(555) 014-1024",
|
||||
addressLine1: "100 Terminal Way",
|
||||
city: "New York",
|
||||
state: "NY",
|
||||
postalCode: "10001",
|
||||
country: "USA",
|
||||
website: "beenvoice.test",
|
||||
},
|
||||
client: {
|
||||
name: "Client Studio",
|
||||
email: "ap@clientstudio.test",
|
||||
addressLine1: "42 Market Street",
|
||||
city: "Brooklyn",
|
||||
state: "NY",
|
||||
postalCode: "11201",
|
||||
country: "USA",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
date: new Date("2026-04-08T12:00:00.000Z"),
|
||||
description: "Invoice workflow design and implementation",
|
||||
hours: 12,
|
||||
rate: 150,
|
||||
amount: 1800,
|
||||
},
|
||||
{
|
||||
date: new Date("2026-04-16T12:00:00.000Z"),
|
||||
description: "Client import cleanup",
|
||||
hours: 5,
|
||||
rate: 150,
|
||||
amount: 750,
|
||||
},
|
||||
{
|
||||
date: new Date("2026-04-24T12:00:00.000Z"),
|
||||
description: "Reporting polish",
|
||||
hours: 4,
|
||||
rate: 150,
|
||||
amount: 600,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function PdfPreviewFrame({
|
||||
settings,
|
||||
businessName,
|
||||
}: {
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
businessName: string;
|
||||
}) {
|
||||
const previewBusinessName =
|
||||
businessName.trim() !== ""
|
||||
? businessName
|
||||
: (previewInvoice.business?.name ?? "Sample Studio");
|
||||
const invoice = {
|
||||
...previewInvoice,
|
||||
business: {
|
||||
...previewInvoice.business,
|
||||
name: previewBusinessName,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-muted/30 overflow-hidden border">
|
||||
<div className="bg-background flex h-10 items-center justify-between border-b px-3">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
PDF preview
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Generated from sample invoice data
|
||||
</span>
|
||||
</div>
|
||||
<BlobProvider
|
||||
document={<InvoicePDF invoice={invoice} settings={settings} />}
|
||||
>
|
||||
{({ url, loading, error }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex aspect-[8.5/11] items-center justify-center p-6 text-sm">
|
||||
Rendering PDF preview...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !url) {
|
||||
return (
|
||||
<div className="text-destructive flex aspect-[8.5/11] items-center justify-center p-6 text-sm">
|
||||
PDF preview could not be rendered.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={url}
|
||||
title="Invoice PDF preview"
|
||||
className="h-[640px] w-full bg-white"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</BlobProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
import { Suspense } from "react";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
import { SettingsContent } from "./_components/settings-content";
|
||||
|
||||
export default async function SettingsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const validTabs = ["general", "preferences", "data", "api"] as const;
|
||||
const initialTab = validTabs.includes(
|
||||
params.tab as (typeof validTabs)[number],
|
||||
)
|
||||
? (params.tab as (typeof validTabs)[number])
|
||||
: "general";
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Settings"
|
||||
description="Manage your account preferences and data"
|
||||
/>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
||||
<SettingsContent initialTab={initialTab} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import { HydrateClient, api } from "~/trpc/server";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { TimeEntriesHistory } from "~/components/time-clock/time-entries-history";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export default async function TimeClockEntriesPage() {
|
||||
void api.timeEntries.getAll.prefetch();
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Time entries"
|
||||
description="Your completed time tracking history"
|
||||
>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/dashboard/time-clock">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Time clock
|
||||
</Link>
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
<HydrateClient>
|
||||
<TimeEntriesHistory />
|
||||
</HydrateClient>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { HydrateClient, api } from "~/trpc/server";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||
|
||||
export default async function TimeClockPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ clientId?: string; invoiceId?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
void api.timeEntries.getRunning.prefetch();
|
||||
void api.clients.getAll.prefetch();
|
||||
if (params.clientId) {
|
||||
void api.invoices.getBillable.prefetch({ clientId: params.clientId });
|
||||
} else {
|
||||
void api.invoices.getBillable.prefetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Time clock"
|
||||
description="Track billable hours and save them directly to an invoice"
|
||||
/>
|
||||
<HydrateClient>
|
||||
<TimeClockPanel
|
||||
defaultClientId={params.clientId}
|
||||
defaultInvoiceId={params.invoiceId}
|
||||
/>
|
||||
</HydrateClient>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type RouterOutputs } from "~/trpc/react";
|
||||
|
||||
// Dashboard stats type from the dashboard router
|
||||
export type DashboardStats = RouterOutputs["dashboard"]["getStats"];
|
||||
|
||||
// Individual invoice type from the invoices router
|
||||
export type Invoice = RouterOutputs["invoices"]["getAll"][number];
|
||||
|
||||
// Recent invoice type (includes client relation)
|
||||
export type RecentInvoice = DashboardStats["recentInvoices"][number];
|
||||
|
||||
// Revenue chart data point
|
||||
export type RevenueChartDataPoint = DashboardStats["revenueChartData"][number];
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { api } from "~/trpc/react";
|
||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||
import { formatLineItemDetail } from "~/lib/invoice-line-item";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, currency = "USD") {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
|
||||
}
|
||||
|
||||
function StatusPill({ status, dueDate }: { status: string; dueDate: Date }) {
|
||||
const overdue = status === "sent" && new Date(dueDate) < new Date();
|
||||
const label = overdue ? "Overdue" : status.charAt(0).toUpperCase() + status.slice(1);
|
||||
const cls = overdue
|
||||
? "bg-red-50 text-red-700 border-red-200"
|
||||
: status === "paid"
|
||||
? "bg-green-50 text-green-700 border-green-200"
|
||||
: "bg-yellow-50 text-yellow-700 border-yellow-200";
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full border px-3 py-0.5 text-xs font-semibold ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicInvoiceView({ token }: { token: string }) {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const { data: invoice, isLoading, error } = api.invoices.getByPublicToken.useQuery({ token });
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!invoice || downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
await generateInvoicePDF({
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
invoicePrefix: invoice.invoicePrefix,
|
||||
issueDate: new Date(invoice.issueDate),
|
||||
dueDate: new Date(invoice.dueDate),
|
||||
status: invoice.status,
|
||||
totalAmount: invoice.totalAmount,
|
||||
taxRate: invoice.taxRate,
|
||||
currency: invoice.currency ?? "USD",
|
||||
notes: invoice.notes,
|
||||
business: invoice.business,
|
||||
client: invoice.client,
|
||||
items: invoice.items,
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to generate PDF");
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error ?? !invoice) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-800">Invoice not found</p>
|
||||
<p className="text-sm text-gray-500">This link may have expired or been revoked.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0);
|
||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||
const total = subtotal + taxAmount;
|
||||
const senderName = invoice.business
|
||||
? invoice.business.nickname
|
||||
? `${invoice.business.name} (${invoice.business.nickname})`
|
||||
: invoice.business.name
|
||||
: null;
|
||||
const hasLogo = Boolean(invoice.business?.logoStorageKey);
|
||||
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-10 px-4">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{/* Card */}
|
||||
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
|
||||
{hasLogo && (
|
||||
// Uploaded SVGs are sanitized and served by our route. next/image's
|
||||
// optimizer intentionally rejects SVG, so a native img is required.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/business-logo/${invoice.business!.id}`}
|
||||
alt=""
|
||||
className="h-16 w-auto max-w-[220px] shrink-0 rounded bg-white object-contain px-2 py-1.5"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{!hideName && (
|
||||
<p className="truncate text-lg font-bold text-white">{senderName ?? "Invoice"}</p>
|
||||
)}
|
||||
{invoice.business?.email && (
|
||||
<p className="mt-0.5 truncate text-sm text-gray-400">{invoice.business.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-8 py-6 space-y-6">
|
||||
{/* Invoice meta */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">{invoice.invoiceNumber}</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Issued {formatDate(invoice.issueDate)} · Due {formatDate(invoice.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill status={invoice.status} dueDate={invoice.dueDate} />
|
||||
</div>
|
||||
|
||||
{/* Bill to */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Bill to</p>
|
||||
<p className="font-semibold text-gray-900">{invoice.client.name}</p>
|
||||
{invoice.client.email && (
|
||||
<p className="text-sm text-gray-500">{invoice.client.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Line items */}
|
||||
<div className="space-y-3">
|
||||
{invoice.items.map((item) => (
|
||||
<div key={item.id} className="flex justify-between gap-4 text-sm">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 break-words">{item.description}</p>
|
||||
<p className="text-gray-500">
|
||||
{formatLineItemDetail(
|
||||
item.hours,
|
||||
item.rate,
|
||||
(amount) => formatCurrency(amount, invoice.currency ?? "USD"),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-semibold text-gray-900 shrink-0">
|
||||
{formatCurrency(item.amount, invoice.currency ?? "USD")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Totals */}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between text-gray-500">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatCurrency(subtotal, invoice.currency ?? "USD")}</span>
|
||||
</div>
|
||||
{invoice.taxRate > 0 && (
|
||||
<div className="flex justify-between text-gray-500">
|
||||
<span>Tax ({invoice.taxRate}%)</span>
|
||||
<span>{formatCurrency(taxAmount, invoice.currency ?? "USD")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-base font-bold text-gray-900 pt-1">
|
||||
<span>Total</span>
|
||||
<span>{formatCurrency(total, invoice.currency ?? "USD")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Notes</p>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{invoice.notes}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* PDF download */}
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{downloading ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Generating PDF…</>
|
||||
) : (
|
||||
<><Download className="mr-2 h-4 w-4" /> Download PDF</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-100 bg-gray-50 px-8 py-4 text-center">
|
||||
<p className="text-xs text-gray-400">Powered by beenvoice</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PublicInvoicePage() {
|
||||
const params = useParams();
|
||||
const token = params.token as string;
|
||||
return <PublicInvoiceView token={token} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
|
||||
/** Public invoice links need tRPC but not authenticated settings sync. */
|
||||
export default function PublicInvoiceLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TRPCReactProvider>
|
||||
<MarketingProviders>{children}</MarketingProviders>
|
||||
</TRPCReactProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import "~/styles/globals.css";
|
||||
|
||||
import { type Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { brand } from "~/lib/branding";
|
||||
|
||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||
import { AppearanceInitScript } from "~/components/layout/appearance-init-script";
|
||||
import { BrandBackground } from "~/components/layout/brand-background";
|
||||
|
||||
const siteTitle = `${brand.name} - Invoicing Made Simple`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(getAppUrl()),
|
||||
title: {
|
||||
default: siteTitle,
|
||||
template: `%s | ${brand.name}`,
|
||||
},
|
||||
description: brand.tagline,
|
||||
openGraph: {
|
||||
title: siteTitle,
|
||||
description: brand.tagline,
|
||||
siteName: brand.name,
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: siteTitle,
|
||||
description: brand.tagline,
|
||||
},
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
};
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "../../public/fonts/geist/sans/Geist-VariableFont_wght.ttf",
|
||||
variable: "--font-geist-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const playfair = localFont({
|
||||
src: "../../node_modules/@fontsource-variable/playfair-display/files/playfair-display-latin-wght-normal.woff2",
|
||||
variable: "--font-playfair",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = localFont({
|
||||
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
|
||||
variable: "--font-geist-mono",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html
|
||||
suppressHydrationWarning
|
||||
lang="en"
|
||||
data-color-mode="system"
|
||||
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
<AppearanceInitScript />
|
||||
</head>
|
||||
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
|
||||
<BrandBackground />
|
||||
|
||||
<div className="relative z-10">{children}</div>
|
||||
<Toaster />
|
||||
<UmamiScript />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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);
|
||||
|
||||
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",
|
||||
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,
|
||||
fontSize: 40,
|
||||
fontWeight: 600,
|
||||
color: "#09090b",
|
||||
textAlign: "center",
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
Invoicing Made Simple
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
fontSize: 22,
|
||||
fontWeight: 400,
|
||||
color: "#71717a",
|
||||
textAlign: "center",
|
||||
maxWidth: 900,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{brand.tagline}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function AuthRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let isCurrent = true;
|
||||
|
||||
async function redirectAuthenticatedUser() {
|
||||
const { data: session } = await authClient.getSession().catch(() => ({
|
||||
data: null,
|
||||
}));
|
||||
|
||||
if (isCurrent && session?.user) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
void redirectAuthenticatedUser();
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
// This component doesn't render anything
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Script from "next/script";
|
||||
import { env } from "~/env";
|
||||
|
||||
export function UmamiScript() {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Script
|
||||
defer
|
||||
src={env.NEXT_PUBLIC_UMAMI_SCRIPT_URL}
|
||||
data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function AuthPageShell({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-dashboard text-foreground flex min-h-screen flex-col px-5 py-6 sm:px-6 sm:py-8">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-md flex-1 flex-col justify-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-muted-foreground hover:text-foreground mb-6 inline-flex items-center gap-2 text-sm transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to home
|
||||
</Link>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background/80 rounded-3xl border p-6 shadow-xl backdrop-blur-xl sm:p-8",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthCardHeader({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 space-y-3">
|
||||
<Logo size="md" animated={false} />
|
||||
<div className="space-y-1">
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
import { useState, useRef } from "react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Card } from "~/components/ui/card";
|
||||
|
||||
interface AddressAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSelect: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface NominatimResult {
|
||||
place_id: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export function AddressAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
onSelect,
|
||||
placeholder,
|
||||
}: AddressAutocompleteProps) {
|
||||
const [suggestions, setSuggestions] = useState<NominatimResult[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const fetchSuggestions = async (query: string) => {
|
||||
if (!query) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`,
|
||||
);
|
||||
const data = (await res.json()) as NominatimResult[];
|
||||
setSuggestions(data);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
onChange(val);
|
||||
setShowSuggestions(true);
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
void fetchSuggestions(val);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSelect = (address: string) => {
|
||||
onSelect(address);
|
||||
setShowSuggestions(false);
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder ?? "Start typing address..."}
|
||||
autoComplete="off"
|
||||
onFocus={() => value && setShowSuggestions(true)}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
||||
/>
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<Card className="bg-card border-border absolute z-10 mt-1 max-h-60 w-full overflow-auto border">
|
||||
<ul>
|
||||
{suggestions.map((s) => (
|
||||
<li
|
||||
key={s.place_id}
|
||||
className="hover:bg-muted cursor-pointer px-4 py-2 text-sm"
|
||||
onMouseDown={() => handleSelect(s.display_name)}
|
||||
>
|
||||
{s.display_name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { brand, splitLogoText } from "~/lib/branding";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface LogoProps {
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg" | "xl" | "icon";
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
||||
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
|
||||
const sizeClasses = {
|
||||
sm: "text-base",
|
||||
md: "text-xl",
|
||||
lg: "text-3xl",
|
||||
xl: "text-5xl",
|
||||
icon: "text-2xl",
|
||||
};
|
||||
|
||||
if (!animated) {
|
||||
return (
|
||||
<LogoContent
|
||||
className={className}
|
||||
size={size}
|
||||
sizeClasses={sizeClasses}
|
||||
logoPrefix={logoPrefix}
|
||||
logoSuffix={logoSuffix}
|
||||
icon={brand.icon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.1, ease: "easeOut" }}
|
||||
className={cn(
|
||||
"flex items-center font-mono",
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
|
||||
className="text-primary font-bold tracking-tight"
|
||||
>
|
||||
{brand.icon}
|
||||
</motion.span>
|
||||
{size !== "icon" && (
|
||||
<>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }}
|
||||
className="inline-block w-1"
|
||||
/>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.04, duration: 0.05, ease: "easeOut" }}
|
||||
className="text-foreground font-bold tracking-tight"
|
||||
>
|
||||
{logoPrefix}
|
||||
</motion.span>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.06, duration: 0.05, ease: "easeOut" }}
|
||||
className="text-foreground/70 font-bold tracking-tight"
|
||||
>
|
||||
{logoSuffix}
|
||||
</motion.span>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoContent({
|
||||
className,
|
||||
size,
|
||||
sizeClasses,
|
||||
logoPrefix,
|
||||
logoSuffix,
|
||||
icon,
|
||||
}: {
|
||||
className?: string;
|
||||
size: "sm" | "md" | "lg" | "xl" | "icon";
|
||||
sizeClasses: Record<string, string>;
|
||||
logoPrefix: string;
|
||||
logoSuffix: string;
|
||||
icon: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center font-mono",
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="text-primary font-bold tracking-tight">{icon}</span>
|
||||
{size !== "icon" && (
|
||||
<>
|
||||
<span className="inline-block w-1" />
|
||||
<span className="text-foreground font-bold tracking-tight">
|
||||
{logoPrefix}
|
||||
</span>
|
||||
<span className="text-foreground/70 font-bold tracking-tight">
|
||||
{logoSuffix}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** @deprecated Use InvoiceImportPage from ~/components/invoice-import-page */
|
||||
export { InvoiceImportPage as CSVImportPage } from "~/components/invoice-import-page";
|
||||
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Plus,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
export function ClientList() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [clientToDelete, setClientToDelete] = useState<string | null>(null);
|
||||
|
||||
const { data: clients, isLoading, refetch } = api.clients.getAll.useQuery();
|
||||
const deleteClient = api.clients.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Client deleted successfully");
|
||||
void refetch();
|
||||
setDeleteDialogOpen(false);
|
||||
setClientToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to delete client");
|
||||
},
|
||||
});
|
||||
|
||||
const filteredClients =
|
||||
clients?.filter(
|
||||
(client) =>
|
||||
client.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
client.email?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
) ?? [];
|
||||
|
||||
const handleDelete = (clientId: string) => {
|
||||
setClientToDelete(clientId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (clientToDelete) {
|
||||
deleteClient.mutate({ id: clientToDelete });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }, (_, i: number) => (
|
||||
<Card key={i} className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<div className="h-4 animate-pulse rounded bg-gray-200" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-gray-200" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!clients || clients.length === 0) {
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-primary text-2xl font-bold">
|
||||
No Clients Yet
|
||||
</CardTitle>
|
||||
<CardDescription className="text-lg">
|
||||
Get started by adding your first client
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Button variant="default" className="h-12 w-full">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Your First Client
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Label htmlFor="search" className="sr-only">
|
||||
Search clients
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Search className="text-muted absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Search by name or email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-12 pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Button variant="default" className="h-12 w-full sm:w-auto">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Client
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredClients.map((client) => (
|
||||
<Card
|
||||
key={client.id}
|
||||
className="group bg-card border-border border transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between text-lg">
|
||||
<span className="text-foreground group-hover:text-primary font-semibold transition-colors">
|
||||
{client.name}
|
||||
</span>
|
||||
<div className="flex space-x-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Link href={`/dashboard/clients/${client.id}`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(client.id)}
|
||||
className="hover:bg-error-subtle hover:text-icon-red h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{client.email && (
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<div className="bg-muted mr-3 rounded p-1.5">
|
||||
<Mail className="text-muted-foreground h-3 w-3" />
|
||||
</div>
|
||||
{client.email}
|
||||
</div>
|
||||
)}
|
||||
{client.phone && (
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<div className="bg-muted mr-3 rounded p-1.5">
|
||||
<Phone className="text-muted-foreground h-3 w-3" />
|
||||
</div>
|
||||
{client.phone}
|
||||
</div>
|
||||
)}
|
||||
{(client.addressLine1 ?? client.city ?? client.state) && (
|
||||
<div className="text-muted-foreground flex items-start text-sm">
|
||||
<div className="bg-muted mt-0.5 mr-3 flex-shrink-0 rounded p-1.5">
|
||||
<MapPin className="text-muted-foreground h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{client.addressLine1 && <div>{client.addressLine1}</div>}
|
||||
{client.addressLine2 && <div>{client.addressLine2}</div>}
|
||||
{(client.city ?? client.state ?? client.postalCode) && (
|
||||
<div>
|
||||
{[client.city, client.state, client.postalCode]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{client.country && <div>{client.country}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="bg-card border-border border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground text-xl font-bold">
|
||||
Delete Client
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Are you sure you want to delete this client? This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar, Clock, Edit, Eye, FileText, Plus, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function CurrentOpenInvoiceCard() {
|
||||
const { data: currentInvoice, isLoading } =
|
||||
api.invoices.getCurrentOpen.useQuery();
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
Current Open Invoice
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentInvoice) {
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
Current Open Invoice
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="py-6 text-center">
|
||||
<FileText className="text-muted-foreground mx-auto mb-3 h-8 w-8" />
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
No open invoice found. Create a new invoice to start tracking your
|
||||
time.
|
||||
</p>
|
||||
<Button asChild variant="default">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create New Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const totalHours =
|
||||
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
||||
const totalAmount = currentInvoice.totalAmount;
|
||||
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
Current Open Invoice
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-secondary text-secondary-foreground text-xs">
|
||||
{currentInvoice.invoiceNumber}
|
||||
</Badge>
|
||||
<Badge className="border text-xs">Draft</Badge>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-primary text-sm font-medium">
|
||||
{formatCurrency(totalAmount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<User className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">Client:</span>
|
||||
<span className="font-medium">{currentInvoice.client?.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">Due:</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(currentInvoice.dueDate)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">Hours:</span>
|
||||
<span className="font-medium">{totalHours.toFixed(1)}h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
||||
<Eye className="mr-2 h-3 w-3" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="default" size="sm" className="flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
||||
<Edit className="mr-2 h-3 w-3" />
|
||||
Continue
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
RowData,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Filter,
|
||||
Search,
|
||||
SearchX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Generic names must match TanStack's declaration for module augmentation.
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
headerClassName?: string;
|
||||
cellClassName?: string;
|
||||
}
|
||||
}
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
searchKey?: string;
|
||||
searchPlaceholder?: string;
|
||||
showColumnVisibility?: boolean;
|
||||
showPagination?: boolean;
|
||||
showSearch?: boolean;
|
||||
pageSize?: number;
|
||||
className?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
filterableColumns?: {
|
||||
id: string;
|
||||
title: string;
|
||||
options: { label: string; value: string }[];
|
||||
}[];
|
||||
onRowClick?: (row: TData) => void;
|
||||
/** Render bulk-action buttons when rows are selected. Receives selected rows and a clear function. */
|
||||
selectionActions?: (
|
||||
selectedRows: TData[],
|
||||
clearSelection: () => void,
|
||||
) => React.ReactNode;
|
||||
initialSorting?: SortingState;
|
||||
/** Shown when the dataset is empty (no rows in DB). */
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
emptyIcon?: React.ReactNode;
|
||||
emptyAction?: React.ReactNode;
|
||||
/** Shown when filters/search hide all rows but data exists. */
|
||||
filteredEmptyTitle?: string;
|
||||
filteredEmptyDescription?: string;
|
||||
}
|
||||
|
||||
export interface DataTableEmptyStateProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Centered empty state for data tables (reuses page EmptyState). */
|
||||
export function DataTableEmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: DataTableEmptyStateProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
className={cn("py-16", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
searchKey: _searchKey,
|
||||
searchPlaceholder = "Search...",
|
||||
showColumnVisibility = true,
|
||||
showPagination = true,
|
||||
showSearch = true,
|
||||
pageSize = 10,
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
filterableColumns = [],
|
||||
onRowClick,
|
||||
selectionActions,
|
||||
initialSorting = [],
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
emptyIcon,
|
||||
emptyAction,
|
||||
filteredEmptyTitle = "No matches for your search",
|
||||
filteredEmptyDescription = "Try adjusting your search or filters.",
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[],
|
||||
);
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [globalFilter, setGlobalFilter] = React.useState("");
|
||||
const [searchInput, setSearchInput] = React.useState("");
|
||||
|
||||
// Mobile detection hook
|
||||
const [isMobile, setIsMobile] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 640); // sm breakpoint
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
// Create responsive columns that properly hide on mobile
|
||||
const responsiveColumns = React.useMemo(() => {
|
||||
return columns.map((column) => ({
|
||||
...column,
|
||||
// Add a meta property to control responsive visibility
|
||||
meta: {
|
||||
...(column.meta ?? {}),
|
||||
headerClassName: column.meta?.headerClassName ?? "",
|
||||
cellClassName: column.meta?.cellClassName ?? "",
|
||||
},
|
||||
}));
|
||||
}, [columns]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: responsiveColumns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: "includesString",
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
globalFilter,
|
||||
},
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: isMobile ? 5 : pageSize,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update page size when mobile state changes
|
||||
React.useEffect(() => {
|
||||
table.setPageSize(isMobile ? 5 : pageSize);
|
||||
}, [isMobile, pageSize, table]);
|
||||
|
||||
// Debounce search input updates to the table's global filter
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setGlobalFilter(searchInput);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchInput]);
|
||||
|
||||
// Keep search input in sync when globalFilter is changed externally (e.g., "Clear filters")
|
||||
React.useEffect(() => {
|
||||
setSearchInput(globalFilter ?? "");
|
||||
}, [globalFilter]);
|
||||
|
||||
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
|
||||
const filteredRowCount = table.getFilteredRowModel().rows.length;
|
||||
const isDatasetEmpty = data.length === 0;
|
||||
const isFilteredEmpty = !isDatasetEmpty && filteredRowCount === 0;
|
||||
|
||||
// Handle row click
|
||||
const handleRowClick = (row: TData, event: React.MouseEvent) => {
|
||||
// Don't trigger row click if clicking on action buttons or their children
|
||||
const target = event.target as HTMLElement;
|
||||
const isActionButton =
|
||||
target.closest('[data-action-button="true"]') ??
|
||||
target.closest("button") ??
|
||||
target.closest("a") ??
|
||||
target.closest('[role="button"]');
|
||||
|
||||
if (isActionButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
onRowClick?.(row);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Header Section */}
|
||||
{(title ?? description) && (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<h3 className="text-foreground text-lg font-semibold">{title}</h3>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Bar Card */}
|
||||
{(showSearch || filterableColumns.length > 0 || showColumnVisibility) && (
|
||||
<Card className="bg-card border-border border">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
{showSearch && (
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="text-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchInput ?? ""}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
className="h-9 w-full pr-3 pl-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{filterableColumns.map((column) => (
|
||||
<Select
|
||||
key={column.id}
|
||||
value={
|
||||
(table.getColumn(column.id)?.getFilterValue() as string) ??
|
||||
"all"
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
table
|
||||
.getColumn(column.id)
|
||||
?.setFilterValue(value === "all" ? "" : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-9 p-0 sm:w-[180px] sm:px-3 [&>svg]:hidden sm:[&>svg]:inline-flex">
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Filter className="text-foreground h-4 w-4 sm:hidden" />
|
||||
<span className="hidden sm:inline">
|
||||
<SelectValue placeholder={column.title} />
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className="gap-0">
|
||||
All {column.title}
|
||||
</SelectItem>
|
||||
{column.options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="gap-0"
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
))}
|
||||
{filterableColumns.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 sm:w-auto sm:px-4"
|
||||
onClick={() => {
|
||||
table.resetColumnFilters();
|
||||
setGlobalFilter("");
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 sm:hidden" />
|
||||
<span className="hidden sm:flex sm:items-center">
|
||||
<Filter className="text-foreground mr-2 h-3.5 w-3.5" />
|
||||
Clear filters
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{showColumnVisibility && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hidden h-9 sm:flex"
|
||||
>
|
||||
Columns <ChevronDown className="ml-2 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[150px]">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Selection Toolbar */}
|
||||
{selectionActions && table.getSelectedRowModel().rows.length > 0 && (
|
||||
<Card className="bg-primary/5 border-primary/20 border">
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{table.getSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectionActions(
|
||||
table.getSelectedRowModel().rows.map((r) => r.original),
|
||||
() => table.resetRowSelection(),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Table Content Card */}
|
||||
<Card className="bg-card border-border overflow-hidden border p-0">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="bg-muted/50 hover:bg-muted/50"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const meta = header.column.columnDef.meta;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={cn(
|
||||
"text-muted-foreground h-9 px-3 text-left align-middle text-xs font-medium sm:h-10 sm:px-4 sm:text-sm [&:has([role=checkbox])]:pr-3",
|
||||
meta?.headerClassName,
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={cn(
|
||||
"hover:bg-muted/20 data-[state=selected]:bg-muted/50 border-border/40 table-row border-b transition-colors",
|
||||
onRowClick && "cursor-pointer",
|
||||
)}
|
||||
onClick={(event) =>
|
||||
onRowClick && handleRowClick(row.original, event)
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const meta = cell.column.columnDef.meta;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
"px-3 py-1.5 align-middle text-xs sm:px-4 sm:py-2 sm:text-sm [&:has([role=checkbox])]:pr-3",
|
||||
meta?.cellClassName,
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-0">
|
||||
{isDatasetEmpty && emptyTitle ? (
|
||||
<DataTableEmptyState
|
||||
icon={emptyIcon}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={emptyAction}
|
||||
/>
|
||||
) : isFilteredEmpty ? (
|
||||
<DataTableEmptyState
|
||||
icon={<SearchX className="h-6 w-6" />}
|
||||
title={filteredEmptyTitle}
|
||||
description={filteredEmptyDescription}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-16 text-center text-sm">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pagination Bar Card */}
|
||||
{showPagination && (
|
||||
<Card className="bg-card border-border border">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm">
|
||||
{table.getFilteredRowModel().rows.length === 0
|
||||
? "No entries"
|
||||
: `Showing ${
|
||||
table.getState().pagination.pageIndex *
|
||||
table.getState().pagination.pageSize +
|
||||
1
|
||||
} to ${Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) *
|
||||
table.getState().pagination.pageSize,
|
||||
table.getFilteredRowModel().rows.length,
|
||||
)} of ${table.getFilteredRowModel().rows.length} entries`}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs sm:hidden">
|
||||
{table.getFilteredRowModel().rows.length === 0
|
||||
? "0"
|
||||
: `${
|
||||
table.getState().pagination.pageIndex *
|
||||
table.getState().pagination.pageSize +
|
||||
1
|
||||
}-${Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) *
|
||||
table.getState().pagination.pageSize,
|
||||
table.getFilteredRowModel().rows.length,
|
||||
)} of ${table.getFilteredRowModel().rows.length}`}
|
||||
</p>
|
||||
<Select
|
||||
value={table.getState().pagination.pageSize.toString()}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<SelectItem key={size} value={size.toString()}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 md:h-8 md:w-8"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
<span className="sr-only">First page</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 md:h-8 md:w-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous page</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<span className="text-muted-foreground text-xs sm:text-sm">
|
||||
<span className="hidden sm:inline">Page </span>
|
||||
<span className="text-foreground font-medium">
|
||||
{table.getState().pagination.pageIndex + 1}
|
||||
</span>
|
||||
<span className="sm:inline"> of </span>
|
||||
<span className="text-foreground font-medium">
|
||||
{table.getPageCount() || 1}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 md:h-8 md:w-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next page</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 md:h-8 md:w-8"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
<span className="sr-only">Last page</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component for sortable column headers
|
||||
export function DataTableColumnHeader({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
column: {
|
||||
getCanSort: () => boolean;
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (isDesc: boolean) => void;
|
||||
};
|
||||
title: string;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn("text-xs sm:text-sm", className)}>{title}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"data-[state=open]:bg-accent -ml-2 h-8 px-2 text-xs font-medium hover:bg-transparent sm:text-sm",
|
||||
className,
|
||||
)}
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
<span className="mr-2">{title}</span>
|
||||
{column.getIsSorted() === "desc" ? (
|
||||
<ArrowUpDown className="h-3 w-3 rotate-180 sm:h-3.5 sm:w-3.5" />
|
||||
) : column.getIsSorted() === "asc" ? (
|
||||
<ArrowUpDown className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
) : (
|
||||
<ArrowUpDown className="text-muted-foreground/50 h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Export skeleton component for loading states
|
||||
export function DataTableSkeleton({
|
||||
columns: _columns = 5,
|
||||
rows = 5,
|
||||
}: {
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter bar skeleton */}
|
||||
<Card className="bg-card border-border border">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="bg-muted/30 h-9 w-full flex-1 animate-pulse sm:max-w-sm"></div>
|
||||
<div className="bg-muted/30 h-9 w-24 animate-pulse"></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<Card className="bg-card border-border overflow-hidden border p-0">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
{/* Mobile: 3 columns, sm: 5 columns, lg: 6 columns */}
|
||||
<TableHead className="h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-24 lg:w-32"></div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden h-12 px-3 text-left align-middle sm:table-cell sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden h-12 px-3 text-left align-middle sm:table-cell sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-10 animate-pulse rounded sm:w-12 lg:w-16"></div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden h-12 px-3 text-left align-middle sm:h-14 sm:px-4 lg:table-cell">
|
||||
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded"></div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<TableRow key={i} className="border-b">
|
||||
{/* Client */}
|
||||
<TableCell className="px-3 py-3 align-middle sm:px-4 sm:py-4">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-24 lg:w-32"></div>
|
||||
</TableCell>
|
||||
{/* Date */}
|
||||
<TableCell className="px-3 py-3 align-middle sm:px-4 sm:py-4">
|
||||
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableCell>
|
||||
{/* Status (sm+) */}
|
||||
<TableCell className="hidden px-3 py-3 align-middle sm:table-cell sm:px-4 sm:py-4">
|
||||
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableCell>
|
||||
{/* Amount (sm+) */}
|
||||
<TableCell className="hidden px-3 py-3 align-middle sm:table-cell sm:px-4 sm:py-4">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableCell>
|
||||
{/* Actions */}
|
||||
<TableCell className="px-3 py-3 align-middle sm:px-4 sm:py-4">
|
||||
<div className="bg-muted/30 h-4 w-10 animate-pulse rounded sm:w-12 lg:w-16"></div>
|
||||
</TableCell>
|
||||
{/* Extra (lg+) */}
|
||||
<TableCell className="hidden px-3 py-3 align-middle sm:px-4 sm:py-4 lg:table-cell">
|
||||
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pagination skeleton */}
|
||||
<Card className="bg-card border-border border">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded text-xs sm:w-32 sm:text-sm"></div>
|
||||
<div className="bg-muted/30 h-8 w-[70px] animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-muted/30 h-8 w-8 animate-pulse rounded"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Trash2, GripVertical, ChevronUp, ChevronDown } from "lucide-react";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface EditableInvoiceItemsProps {
|
||||
items: InvoiceItem[];
|
||||
onItemsChange: (items: InvoiceItem[]) => void;
|
||||
onRemoveItem: (index: number) => void;
|
||||
}
|
||||
|
||||
function SortableItem({
|
||||
item,
|
||||
index,
|
||||
onItemChange,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
}: {
|
||||
item: InvoiceItem;
|
||||
index: number;
|
||||
onItemChange: (
|
||||
index: number,
|
||||
field: string,
|
||||
value: string | number | Date,
|
||||
) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onMoveUp: (index: number) => void;
|
||||
onMoveDown: (index: number) => void;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const handleItemChange = (field: string, value: string | number | Date) => {
|
||||
onItemChange(index, field, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`card-secondary transition-colors ${
|
||||
isDragging ? "opacity-50 shadow-lg" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Desktop Layout - Hidden on Mobile */}
|
||||
<div className="hidden items-center gap-3 p-4 md:grid md:grid-cols-12">
|
||||
{/* Drag Handle */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="text-muted-foreground hover:bg-muted hover:text-foreground cursor-grab rounded p-2 transition-colors active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="col-span-2">
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) =>
|
||||
handleItemChange("date", date ?? new Date())
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="col-span-4">
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) => handleItemChange("description", e.target.value)}
|
||||
placeholder="Work description"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours */}
|
||||
<div className="col-span-1">
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => handleItemChange("hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
placeholder="0"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rate */}
|
||||
<div className="col-span-2">
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => handleItemChange("rate", value)}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
prefix="$"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted/30 text-primary flex h-9 items-center border px-3 font-medium">
|
||||
${item.amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<div className="col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive/80 h-9 w-9 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Layout - Visible on Mobile Only */}
|
||||
<div className="space-y-4 p-4 md:hidden">
|
||||
{/* Header with Item Number and Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Item {index + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onMoveUp(index)}
|
||||
disabled={!canMoveUp}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onMoveDown(index)}
|
||||
disabled={!canMoveDown}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive/80 h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Description</Label>
|
||||
<Textarea
|
||||
value={item.description}
|
||||
onChange={(e) => handleItemChange("description", e.target.value)}
|
||||
placeholder="Description of work..."
|
||||
className="min-h-[48px] resize-none text-sm"
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Date</Label>
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) =>
|
||||
handleItemChange("date", date ?? new Date())
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours and Rate */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Hours</Label>
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => handleItemChange("hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
placeholder="0"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Rate</Label>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => handleItemChange("rate", value)}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
prefix="$"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="bg-muted/20 border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">Total Amount:</span>
|
||||
<span className="text-primary font-mono text-lg font-bold">
|
||||
${item.amount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditableInvoiceItems({
|
||||
items,
|
||||
onItemsChange,
|
||||
onRemoveItem,
|
||||
}: EditableInvoiceItemsProps) {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over?.id);
|
||||
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
onItemsChange(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemChange = (
|
||||
index: number,
|
||||
field: string,
|
||||
value: string | number | Date,
|
||||
) => {
|
||||
const newItems = [...items];
|
||||
if (field === "hours" || field === "rate") {
|
||||
if (newItems[index]) {
|
||||
const numValue =
|
||||
typeof value === "string"
|
||||
? parseFloat(value)
|
||||
: typeof value === "number"
|
||||
? value
|
||||
: 0;
|
||||
newItems[index][field] = numValue || 0;
|
||||
newItems[index].amount = newItems[index].hours * newItems[index].rate;
|
||||
}
|
||||
} else if (field === "date") {
|
||||
if (newItems[index]) {
|
||||
const dateValue =
|
||||
value instanceof Date ? value : new Date(String(value));
|
||||
newItems[index].date = dateValue;
|
||||
}
|
||||
} else {
|
||||
if (newItems[index]) {
|
||||
const stringValue = typeof value === "string" ? value : String(value);
|
||||
newItems[index].description = stringValue;
|
||||
}
|
||||
}
|
||||
onItemsChange(newItems);
|
||||
};
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index > 0) {
|
||||
const newItems = arrayMove(items, index, index - 1);
|
||||
onItemsChange(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (index < items.length - 1) {
|
||||
const newItems = arrayMove(items, index, index + 1);
|
||||
onItemsChange(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
// Show skeleton loading on server-side
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((item, _index) => (
|
||||
<div key={item.id} className="card-secondary animate-pulse p-4">
|
||||
{/* Desktop Skeleton */}
|
||||
<div className="hidden grid-cols-12 gap-3 md:grid">
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted h-4 w-4 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted h-9 w-9 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile Skeleton */}
|
||||
<div className="space-y-3 md:hidden">
|
||||
<div className="bg-muted h-4 w-20 rounded"></div>
|
||||
<div className="bg-muted h-16 rounded"></div>
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="bg-muted h-12 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Header Labels - Hidden on Mobile */}
|
||||
<div className="text-muted-foreground hidden items-center gap-3 px-4 pb-2 text-xs font-medium md:grid md:grid-cols-12">
|
||||
<div className="col-span-1"></div>
|
||||
<div className="col-span-2">Date</div>
|
||||
<div className="col-span-4">Description</div>
|
||||
<div className="col-span-1">Hours</div>
|
||||
<div className="col-span-2">Rate</div>
|
||||
<div className="col-span-1">Amount</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<SortableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onItemChange={handleItemChange}
|
||||
onRemove={onRemoveItem}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < items.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Plus,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
export function InvoiceList() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [invoiceToDelete, setInvoiceToDelete] = useState<string | null>(null);
|
||||
|
||||
const { data: invoices, isLoading, refetch } = api.invoices.getAll.useQuery();
|
||||
const deleteInvoice = api.invoices.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Invoice deleted successfully");
|
||||
void refetch();
|
||||
setDeleteDialogOpen(false);
|
||||
setInvoiceToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message ?? "Failed to delete invoice");
|
||||
},
|
||||
});
|
||||
|
||||
const filteredInvoices =
|
||||
invoices?.filter(
|
||||
(invoice) =>
|
||||
invoice.invoiceNumber
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
invoice.client.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
) ?? [];
|
||||
|
||||
const handleDelete = (invoiceId: string) => {
|
||||
setInvoiceToDelete(invoiceId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (invoiceToDelete) {
|
||||
deleteInvoice.mutate({ id: invoiceToDelete });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString();
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<div className="bg-muted h-4 animate-pulse rounded" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted h-3 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-2/3 animate-pulse rounded" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoices || invoices.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>No Invoices Yet</CardTitle>
|
||||
<CardDescription>
|
||||
Get started by creating your first invoice
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Button className="w-full">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Your First Invoice
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="search">Search invoices</Label>
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Search by invoice number or client..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Invoice
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredInvoices.map((invoice) => (
|
||||
<Card key={invoice.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="truncate">{invoice.invoiceNumber}</span>
|
||||
<div className="flex space-x-1">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{invoice.status === "draft" ? (
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled
|
||||
title="Only draft invoices can be edited"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(invoice.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<StatusBadge status={invoice.status as StatusType} />
|
||||
<span className="text-primary text-lg font-bold">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
{invoice.client.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Due: {formatDate(invoice.dueDate)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
{invoice.items.length} item
|
||||
{invoice.items.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this invoice? This action cannot
|
||||
be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import * as React from "react";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
variant?: "default" | "success" | "warning" | "error" | "info";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default: {
|
||||
icon: "text-foreground",
|
||||
background: "bg-muted/50",
|
||||
},
|
||||
success: {
|
||||
icon: "text-primary",
|
||||
background: "bg-primary/10",
|
||||
},
|
||||
warning: {
|
||||
icon: "text-status-warning",
|
||||
background: "bg-status-warning-muted",
|
||||
},
|
||||
error: {
|
||||
icon: "text-status-error",
|
||||
background: "bg-status-error-muted",
|
||||
},
|
||||
info: {
|
||||
icon: "text-status-info",
|
||||
background: "bg-status-info-muted",
|
||||
},
|
||||
};
|
||||
|
||||
export function StatsCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
icon: Icon,
|
||||
trend,
|
||||
variant = "default",
|
||||
className,
|
||||
}: StatsCardProps) {
|
||||
const styles = variantStyles[variant];
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"border-0 shadow-md transition-shadow hover:shadow-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">{title}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
{trend && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
trend.isPositive ? "text-primary" : "text-destructive",
|
||||
)}
|
||||
>
|
||||
{trend.isPositive ? "+" : ""}
|
||||
{trend.value}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-xs">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{Icon && (
|
||||
<div className={cn("p-3", styles.background)}>
|
||||
<Icon className={cn("h-6 w-6", styles.icon)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsCardSkeleton() {
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-muted mb-2 h-4 w-1/2 rounded"></div>
|
||||
<div className="bg-muted mb-2 h-8 w-3/4 rounded"></div>
|
||||
<div className="bg-muted h-3 w-1/3 rounded"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type StatusType =
|
||||
| "draft"
|
||||
| "sent"
|
||||
| "paid"
|
||||
| "overdue"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info";
|
||||
|
||||
interface StatusBadgeProps
|
||||
extends Omit<React.ComponentProps<typeof Badge>, "variant"> {
|
||||
status: StatusType;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const statusClassMap: Record<StatusType, string> = {
|
||||
draft: "border-muted-foreground/40 bg-muted text-muted-foreground shadow-sm",
|
||||
sent: "border-primary/40 bg-primary/10 text-primary shadow-sm",
|
||||
paid: "border-primary/40 bg-primary/10 text-primary shadow-sm",
|
||||
overdue: "border-destructive/40 bg-destructive/10 text-destructive shadow-sm",
|
||||
success: "border-primary/40 bg-primary/10 text-primary shadow-sm",
|
||||
warning:
|
||||
"border-muted-foreground/40 bg-muted text-muted-foreground shadow-sm",
|
||||
error: "border-destructive/40 bg-destructive/10 text-destructive shadow-sm",
|
||||
info: "border-primary/40 bg-primary/10 text-primary shadow-sm",
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<StatusType, string> = {
|
||||
draft: "Draft",
|
||||
sent: "Sent",
|
||||
paid: "Paid",
|
||||
overdue: "Overdue",
|
||||
success: "Success",
|
||||
warning: "Warning",
|
||||
error: "Error",
|
||||
info: "Info",
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
status,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: StatusBadgeProps) {
|
||||
const statusClass = statusClassMap[status];
|
||||
const label = children ?? statusLabelMap[status];
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
statusClass,
|
||||
"transition-all duration-200 hover:scale-105",
|
||||
status === "sent" && "animate-pulse",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export { type StatusType };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user