feat: add administration page and account role management

- Implemented `AdministrationContent` component for managing account roles.
- Created `AdministrationPage` to serve as the main entry point for administration tasks.
- Added PDF preview functionality with `PdfPreviewFrame` component for invoice generation.
- Introduced `InputColor` component for advanced color selection with various formats.
- Established color conversion utilities in `color-converter.ts` for handling color formats.
- Defined appearance-related schemas and types in `appearance.ts` for consistent theme management.
This commit is contained in:
2026-04-30 10:50:50 -04:00
parent ddc2b42672
commit 0e46fdafb2
87 changed files with 4566 additions and 2425 deletions
+109 -104
View File
@@ -3,123 +3,128 @@ import { invoices, clients } from "~/server/db/schema";
import { eq, desc } from "drizzle-orm";
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);
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.
// 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 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),
);
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";
};
// 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;
// Calculate Stats
let totalRevenue = 0;
let pendingAmount = 0;
let overdueCount = 0;
let currentMonthRevenue = 0;
let lastMonthRevenue = 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);
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 (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++;
}
if (issueDate >= currentMonthStart) {
currentMonthRevenue += amount;
} else if (
issueDate >= lastMonthStart &&
issueDate < currentMonthStart
) {
lastMonthRevenue += amount;
}
} else if (status === "sent" || status === "overdue") {
pendingAmount += amount;
}
// 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;
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;
}
}
}
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));
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)],
limit: 5,
with: {
client: {
columns: { name: true },
},
},
});
// Recent Activity
const recentInvoices = await ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
orderBy: [desc(invoices.issueDate)],
limit: 5,
with: {
client: {
columns: { name: true },
},
},
});
return {
totalRevenue,
pendingAmount,
overdueCount,
totalClients: userClientsCount,
revenueChange: lastMonthRevenue > 0
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
: 0,
revenueChartData,
recentInvoices,
};
}),
return {
totalRevenue,
pendingAmount,
overdueCount,
totalClients: userClientsCount,
revenueChange:
lastMonthRevenue > 0
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
: 0,
revenueChartData,
recentInvoices,
};
}),
});