Archived
Unify the dashboard experience and retire the multi-theme engine so onboarding and day-to-day invoicing feel consistent and easier to maintain.
Shared layout, tabs, and sidebar timer; user onboarding and registration polish; settings danger zone and data export; chart and tRPC perf fixes; migrations for onboarding and dropped appearance columns. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+228
-139
@@ -1,155 +1,244 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import { invoices, clients } from "~/server/db/schema";
|
||||
import { and, desc, eq, isNotNull, lte } from "drizzle-orm";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import { clients, invoices } from "~/server/db/schema";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
|
||||
type LiteInvoice = {
|
||||
id: string;
|
||||
totalAmount: number;
|
||||
status: string;
|
||||
dueDate: Date;
|
||||
issueDate: Date;
|
||||
};
|
||||
|
||||
function buildRevenueMonthKeys(now: Date, count: number) {
|
||||
const keys: string[] = [];
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
keys.push(
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
|
||||
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
|
||||
let totalRevenue = 0;
|
||||
let pendingAmount = 0;
|
||||
let overdueCount = 0;
|
||||
let currentMonthRevenue = 0;
|
||||
let lastMonthRevenue = 0;
|
||||
|
||||
const revenueByMonth = Object.fromEntries(
|
||||
buildRevenueMonthKeys(now, 6).map((key) => [key, 0]),
|
||||
) as Record<string, number>;
|
||||
|
||||
const statusTotals: Record<
|
||||
string,
|
||||
{ status: string; count: number; value: number }
|
||||
> = {};
|
||||
|
||||
const monthlyTotals: Record<
|
||||
string,
|
||||
{
|
||||
month: string;
|
||||
totalInvoices: number;
|
||||
paidInvoices: number;
|
||||
pendingInvoices: number;
|
||||
overdueInvoices: number;
|
||||
draftInvoices: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const inv of userInvoices) {
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
);
|
||||
const amount = inv.totalAmount;
|
||||
const issueDate = new Date(inv.issueDate);
|
||||
|
||||
if (effectiveStatus === "paid") {
|
||||
totalRevenue += amount;
|
||||
|
||||
if (issueDate >= currentMonthStart) {
|
||||
currentMonthRevenue += amount;
|
||||
} else if (
|
||||
issueDate >= lastMonthStart &&
|
||||
issueDate < currentMonthStart
|
||||
) {
|
||||
lastMonthRevenue += amount;
|
||||
}
|
||||
|
||||
const revenueKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
|
||||
const monthRevenue = revenueByMonth[revenueKey];
|
||||
if (monthRevenue !== undefined) {
|
||||
revenueByMonth[revenueKey] = monthRevenue + amount;
|
||||
}
|
||||
} else if (effectiveStatus === "sent" || effectiveStatus === "overdue") {
|
||||
pendingAmount += amount;
|
||||
}
|
||||
|
||||
if (effectiveStatus === "overdue") {
|
||||
overdueCount++;
|
||||
}
|
||||
|
||||
statusTotals[effectiveStatus] ??= {
|
||||
status: effectiveStatus,
|
||||
count: 0,
|
||||
value: 0,
|
||||
};
|
||||
statusTotals[effectiveStatus].count += 1;
|
||||
statusTotals[effectiveStatus].value += amount;
|
||||
|
||||
const monthKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
|
||||
monthlyTotals[monthKey] ??= {
|
||||
month: monthKey,
|
||||
totalInvoices: 0,
|
||||
paidInvoices: 0,
|
||||
pendingInvoices: 0,
|
||||
overdueInvoices: 0,
|
||||
draftInvoices: 0,
|
||||
};
|
||||
monthlyTotals[monthKey].totalInvoices += 1;
|
||||
|
||||
switch (effectiveStatus) {
|
||||
case "paid":
|
||||
monthlyTotals[monthKey].paidInvoices += 1;
|
||||
break;
|
||||
case "sent":
|
||||
monthlyTotals[monthKey].pendingInvoices += 1;
|
||||
break;
|
||||
case "overdue":
|
||||
monthlyTotals[monthKey].overdueInvoices += 1;
|
||||
break;
|
||||
case "draft":
|
||||
monthlyTotals[monthKey].draftInvoices += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const revenueChartData = Object.entries(revenueByMonth)
|
||||
.map(([month, revenue]) => ({
|
||||
month,
|
||||
revenue,
|
||||
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
|
||||
const statusChartData = Object.values(statusTotals).map((item) => ({
|
||||
...item,
|
||||
name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
|
||||
}));
|
||||
|
||||
const monthlyMetricsChartData = Object.values(monthlyTotals)
|
||||
.sort((a, b) => a.month.localeCompare(b.month))
|
||||
.slice(-6)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}),
|
||||
}));
|
||||
|
||||
return {
|
||||
totalRevenue,
|
||||
pendingAmount,
|
||||
overdueCount,
|
||||
revenueChange:
|
||||
lastMonthRevenue > 0
|
||||
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
|
||||
: 0,
|
||||
revenueChartData,
|
||||
statusChartData,
|
||||
monthlyMetricsChartData,
|
||||
};
|
||||
}
|
||||
|
||||
export const dashboardRouter = createTRPCRouter({
|
||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
const now = new Date();
|
||||
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
|
||||
// 1. Fetch all invoices for the user to calculate stats
|
||||
// Note: For very large datasets, we should use separate count/sum queries,
|
||||
// but for typical usage, fetching fields is fine and allows flexible JS calculation
|
||||
// where SQL complexity might be high (e.g. dynamic status).
|
||||
// However, let's try to be efficient with SQL where possible.
|
||||
|
||||
const userInvoices = await ctx.db.query.invoices.findMany({
|
||||
where: eq(invoices.createdById, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
totalAmount: true,
|
||||
status: true,
|
||||
dueDate: true,
|
||||
issueDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userClientsCount = await ctx.db.$count(
|
||||
clients,
|
||||
eq(clients.createdById, userId),
|
||||
);
|
||||
|
||||
// Helper to check status
|
||||
const getStatus = (inv: (typeof userInvoices)[0]) => {
|
||||
if (inv.status === "paid") return "paid";
|
||||
if (inv.status === "draft") return "draft";
|
||||
if (new Date(inv.dueDate) < now && inv.status !== "paid")
|
||||
return "overdue";
|
||||
return "sent";
|
||||
};
|
||||
|
||||
// Calculate Stats
|
||||
let totalRevenue = 0;
|
||||
let pendingAmount = 0;
|
||||
let overdueCount = 0;
|
||||
|
||||
let currentMonthRevenue = 0;
|
||||
let lastMonthRevenue = 0;
|
||||
|
||||
for (const inv of userInvoices) {
|
||||
const status = getStatus(inv);
|
||||
const amount = inv.totalAmount;
|
||||
const issueDate = new Date(inv.issueDate);
|
||||
|
||||
if (status === "paid") {
|
||||
totalRevenue += amount;
|
||||
|
||||
if (issueDate >= currentMonthStart) {
|
||||
currentMonthRevenue += amount;
|
||||
} else if (
|
||||
issueDate >= lastMonthStart &&
|
||||
issueDate < currentMonthStart
|
||||
) {
|
||||
lastMonthRevenue += amount;
|
||||
}
|
||||
} else if (status === "sent" || status === "overdue") {
|
||||
pendingAmount += amount;
|
||||
}
|
||||
|
||||
if (status === "overdue") {
|
||||
overdueCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Revenue Trend (Last 6 months)
|
||||
const revenueByMonth: Record<string, number> = {};
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
revenueByMonth[key] = 0;
|
||||
}
|
||||
|
||||
for (const inv of userInvoices) {
|
||||
if (getStatus(inv) === "paid") {
|
||||
const d = new Date(inv.issueDate);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
if (revenueByMonth[key] !== undefined) {
|
||||
revenueByMonth[key] += inv.totalAmount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const revenueChartData = Object.entries(revenueByMonth)
|
||||
.map(([month, revenue]) => ({
|
||||
month,
|
||||
revenue,
|
||||
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
|
||||
// Recent Activity
|
||||
const recentInvoices = await ctx.db.query.invoices.findMany({
|
||||
where: eq(invoices.createdById, userId),
|
||||
orderBy: [
|
||||
desc(invoices.issueDate),
|
||||
desc(invoices.dueDate),
|
||||
desc(invoices.invoiceNumber),
|
||||
],
|
||||
limit: 5,
|
||||
with: {
|
||||
client: {
|
||||
columns: { name: true },
|
||||
const [
|
||||
userInvoices,
|
||||
userClientsCount,
|
||||
recentInvoices,
|
||||
currentDraft,
|
||||
] = await Promise.all([
|
||||
ctx.db.query.invoices.findMany({
|
||||
where: eq(invoices.createdById, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
totalAmount: true,
|
||||
status: true,
|
||||
dueDate: true,
|
||||
issueDate: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
ctx.db.$count(clients, eq(clients.createdById, userId)),
|
||||
ctx.db.query.invoices.findMany({
|
||||
where: eq(invoices.createdById, userId),
|
||||
orderBy: [
|
||||
desc(invoices.issueDate),
|
||||
desc(invoices.dueDate),
|
||||
desc(invoices.invoiceNumber),
|
||||
],
|
||||
limit: 5,
|
||||
with: {
|
||||
client: {
|
||||
columns: { name: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.db.query.invoices.findFirst({
|
||||
where: and(
|
||||
eq(invoices.createdById, userId),
|
||||
eq(invoices.status, "draft"),
|
||||
),
|
||||
orderBy: [
|
||||
desc(invoices.issueDate),
|
||||
desc(invoices.dueDate),
|
||||
desc(invoices.invoiceNumber),
|
||||
],
|
||||
columns: {
|
||||
id: true,
|
||||
invoiceNumber: true,
|
||||
totalAmount: true,
|
||||
},
|
||||
with: {
|
||||
client: { columns: { name: true } },
|
||||
items: { columns: { hours: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const sendReminderDue = await ctx.db.query.invoices.findMany({
|
||||
where: and(
|
||||
eq(invoices.createdById, userId),
|
||||
eq(invoices.status, "draft"),
|
||||
isNotNull(invoices.sendReminderAt),
|
||||
lte(invoices.sendReminderAt, now),
|
||||
),
|
||||
columns: {
|
||||
id: true,
|
||||
invoiceNumber: true,
|
||||
invoicePrefix: true,
|
||||
sendReminderAt: true,
|
||||
},
|
||||
with: {
|
||||
client: { columns: { name: true } },
|
||||
},
|
||||
orderBy: [desc(invoices.sendReminderAt)],
|
||||
limit: 10,
|
||||
});
|
||||
const metrics = aggregateDashboardMetrics(userInvoices, now);
|
||||
|
||||
return {
|
||||
totalRevenue,
|
||||
pendingAmount,
|
||||
overdueCount,
|
||||
...metrics,
|
||||
totalClients: userClientsCount,
|
||||
revenueChange:
|
||||
lastMonthRevenue > 0
|
||||
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
|
||||
: 0,
|
||||
revenueChartData,
|
||||
recentInvoices,
|
||||
sendReminderDue,
|
||||
currentDraft: currentDraft
|
||||
? {
|
||||
id: currentDraft.id,
|
||||
invoiceNumber: currentDraft.invoiceNumber,
|
||||
totalAmount: currentDraft.totalAmount,
|
||||
client: currentDraft.client,
|
||||
totalHours: currentDraft.items.reduce(
|
||||
(sum, item) => sum + item.hours,
|
||||
0,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import { invoices, platformSettings } from "~/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { env } from "~/env";
|
||||
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||
|
||||
@@ -153,7 +155,7 @@ export const emailRouter = createTRPCRouter({
|
||||
customMessage,
|
||||
userName,
|
||||
userEmail,
|
||||
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
||||
baseUrl: getAppUrl(),
|
||||
});
|
||||
|
||||
// Determine Resend instance and email configuration to use
|
||||
@@ -177,7 +179,7 @@ export const emailRouter = createTRPCRouter({
|
||||
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
|
||||
} else if (env.RESEND_API_KEY) {
|
||||
resendInstance = new Resend(env.RESEND_API_KEY);
|
||||
fromEmail = invoice.business?.email ?? "noreply@example.com";
|
||||
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Email delivery is not configured. Add a Resend API key globally or on this business.",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
import { Resend } from "resend";
|
||||
import { env } from "~/env";
|
||||
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||
import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
|
||||
import type { db } from "~/server/db";
|
||||
|
||||
@@ -844,7 +845,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
|
||||
} else if (env.RESEND_API_KEY) {
|
||||
resendInstance = new Resend(env.RESEND_API_KEY);
|
||||
fromEmail = invoice.business?.email ?? "noreply@example.com";
|
||||
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
|
||||
} else {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
|
||||
+585
-216
File diff suppressed because it is too large
Load Diff
@@ -133,18 +133,13 @@ export const createTRPCRouter = t.router;
|
||||
*/
|
||||
const timingMiddleware = t.middleware(async ({ next, path }) => {
|
||||
const start = Date.now();
|
||||
const result = await next();
|
||||
const end = Date.now();
|
||||
|
||||
if (t._config.isDev) {
|
||||
// artificial delay in dev
|
||||
const waitMs = Math.floor(Math.random() * 400) + 100;
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
|
||||
}
|
||||
|
||||
const result = await next();
|
||||
|
||||
const end = Date.now();
|
||||
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
|
||||
+1
-25
@@ -32,37 +32,13 @@ export const users = createTable("user", (d) => ({
|
||||
// Custom fields
|
||||
prefersReducedMotion: d.boolean().default(false).notNull(),
|
||||
animationSpeedMultiplier: d.real().default(1).notNull(),
|
||||
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
|
||||
customColor: d.varchar({ length: 50 }),
|
||||
theme: d.varchar({ length: 20 }).default("system").notNull(),
|
||||
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
|
||||
fontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
||||
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
||||
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
||||
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
|
||||
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
|
||||
role: d.varchar({ length: 20 }).default("user").notNull(),
|
||||
onboardingCompletedAt: d.timestamp(),
|
||||
}));
|
||||
|
||||
export const platformSettings = createTable("platform_setting", (d) => ({
|
||||
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
|
||||
brandName: d.varchar({ length: 100 }).default("beenvoice").notNull(),
|
||||
brandTagline: d
|
||||
.varchar({ length: 255 })
|
||||
.default(
|
||||
"Simple and efficient invoicing for freelancers and small businesses",
|
||||
)
|
||||
.notNull(),
|
||||
brandLogoText: d.varchar({ length: 100 }).default("beenvoice").notNull(),
|
||||
brandIcon: d.varchar({ length: 20 }).default("$").notNull(),
|
||||
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
|
||||
customColor: d.varchar({ length: 50 }),
|
||||
theme: d.varchar({ length: 20 }).default("system").notNull(),
|
||||
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
|
||||
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
||||
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
||||
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
|
||||
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
|
||||
pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(),
|
||||
pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(),
|
||||
pdfFooterText: d
|
||||
|
||||
Reference in New Issue
Block a user