add receipts support

This commit is contained in:
2026-06-26 23:07:43 -04:00
parent 85df7c4627
commit b9a9b813d2
55 changed files with 3239 additions and 441 deletions
+3 -66
View File
@@ -2,12 +2,7 @@ import { type NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
import { Resend } from "resend";
import { env } from "~/env";
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url";
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
import crypto from "crypto";
import { sendPasswordResetForUser } from "~/lib/password-reset";
export async function POST(request: NextRequest) {
try {
@@ -17,7 +12,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
@@ -26,13 +20,11 @@ export async function POST(request: NextRequest) {
);
}
// Check if user exists
const user = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()),
columns: { id: true },
});
// Always return success to prevent email enumeration attacks
// Don't reveal whether the user exists or not
if (!user) {
return NextResponse.json(
{
@@ -44,62 +36,7 @@ export async function POST(request: NextRequest) {
);
}
// Generate reset token
const resetToken = crypto.randomBytes(32).toString("hex");
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Update user with reset token
await db
.update(users)
.set({
resetToken,
resetTokenExpiry,
})
.where(eq(users.id, user.id));
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return NextResponse.json(
{
success: true,
message:
"If an account with that email exists, password reset instructions have been sent.",
},
{ status: 200 },
);
}
// Send password reset email using Resend
try {
const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: email,
userName: user.name ?? undefined,
resetToken,
resetUrl,
expiryHours: 24,
});
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
await resend.emails.send({
from: `beenvoice <noreply@${fromDomain}>`,
to: email,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text,
});
console.log(`Password reset email sent to: ${email}`);
} catch (emailError) {
console.error("Failed to send password reset email:", emailError);
// Continue execution - don't fail the request if email fails
// This prevents revealing whether an account exists based on email delivery
}
await sendPasswordResetForUser(user.id);
return NextResponse.json(
{
+6
View File
@@ -42,6 +42,12 @@ export async function GET(
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,
+39
View File
@@ -0,0 +1,39 @@
import { 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 || 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 });
}
}
@@ -1,7 +1,39 @@
"use client";
import { Shield } from "lucide-react";
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,
@@ -9,6 +41,15 @@ import {
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,
@@ -18,84 +59,563 @@ import {
} from "~/components/ui/select";
import { api } from "~/trpc/react";
export function AdministrationContent() {
const {
data: accounts = [],
refetch,
error,
} = api.settings.listAccounts.useQuery();
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({
onSuccess: () => {
toast.success("Account role updated");
void refetch();
},
onError: (mutationError: { message: string }) => {
toast.error(`Failed to update role: ${mutationError.message}`);
},
});
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 className="bg-card border-border border">
<Card>
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<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" />
Administration
Platform overview
</CardTitle>
<CardDescription>
Administrative access is required for this page.
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 className="bg-card border-border border">
<>
<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="text-foreground flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
Accounts
<CardTitle className="flex items-center gap-2">
<ScrollText className="text-primary h-5 w-5" />
Audit log
</CardTitle>
<CardDescription>
Manage account access and roles without opening customer data.
Recent administrative actions across the platform.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{accounts.map((account) => (
<div
key={account.id}
className="border-border flex flex-col gap-3 border p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="text-sm font-medium">{account.name}</p>
<p className="text-muted-foreground truncate text-xs">
{account.email}
</p>
<p className="text-muted-foreground mt-1 text-xs">
Created {new Date(account.createdAt).toLocaleDateString()}
</p>
</div>
<Select
value={account.role}
onValueChange={(role) =>
updateAccountRoleMutation.mutate({
userId: account.id,
role: role as "user" | "admin",
})
}
>
<SelectTrigger className="w-full sm:w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<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>
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export default async function AdministrationPage() {
<DashboardPage>
<DashboardPageHeader
title="Administration"
description="Manage account access and platform administration"
description="Platform statistics, user management, and audit logging"
/>
<HydrateClient>
+226 -11
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { api } from "~/trpc/react";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
@@ -28,8 +28,17 @@ import {
} from "~/components/ui/select";
import { DatePicker } from "~/components/ui/date-picker";
import { NumberInput } from "~/components/ui/number-input";
import { FileUpload } from "~/components/forms/file-upload";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
import {
Plus,
Pencil,
Trash2,
Receipt,
FileText,
Paperclip,
ExternalLink,
} from "lucide-react";
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
@@ -44,6 +53,7 @@ interface ExpenseFormData {
taxDeductible: boolean;
notes: string;
clientId: string;
businessId: string;
}
const defaultForm: ExpenseFormData = {
@@ -57,24 +67,49 @@ const defaultForm: ExpenseFormData = {
taxDeductible: false,
notes: "",
clientId: "",
businessId: "",
};
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function ExpensesPage() {
const [open, setOpen] = useState(false);
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 utils = api.useUtils();
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery();
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 { data: receipts = [] } = api.expenses.listReceipts.useQuery(
{ expenseId: editId! },
{ enabled: !!editId },
);
const defaultBusinessId = useMemo(
() => businesses.find((b) => b.isDefault)?.id ?? businesses[0]?.id ?? "",
[businesses],
);
useEffect(() => {
if (!open || editId || !defaultBusinessId || form.businessId) return;
setForm((prev) => ({ ...prev, businessId: defaultBusinessId }));
}, [open, editId, defaultBusinessId, form.businessId]);
const create = api.expenses.create.useMutation({
onSuccess: () => {
onSuccess: (expense) => {
if (!expense) return;
toast.success("Expense added");
void utils.expenses.getAll.invalidate();
setOpen(false);
setForm(defaultForm);
setEditId(expense.id);
},
onError: (e) => toast.error(e.message),
});
@@ -96,10 +131,30 @@ export default function ExpensesPage() {
},
onError: (e) => toast.error(e.message),
});
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
onSuccess: () => {
toast.success("Receipt uploaded");
if (editId) {
void utils.expenses.listReceipts.invalidate({ expenseId: editId });
void utils.expenses.getAll.invalidate();
}
},
onError: (e) => toast.error(e.message),
});
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
onSuccess: () => {
toast.success("Receipt removed");
if (editId) {
void utils.expenses.listReceipts.invalidate({ expenseId: editId });
void utils.expenses.getAll.invalidate();
}
},
onError: (e) => toast.error(e.message),
});
const handleOpen = () => {
setEditId(null);
setForm(defaultForm);
setForm({ ...defaultForm, businessId: defaultBusinessId });
setOpen(true);
};
const handleEdit = (expense: (typeof expenses)[0]) => {
@@ -115,6 +170,7 @@ export default function ExpensesPage() {
taxDeductible: expense.taxDeductible ?? false,
notes: expense.notes ?? "",
clientId: expense.clientId ?? "",
businessId: expense.businessId ?? defaultBusinessId,
});
setOpen(true);
};
@@ -130,6 +186,7 @@ export default function ExpensesPage() {
const payload = {
...form,
clientId: form.clientId || undefined,
businessId: form.businessId || undefined,
category: form.category || undefined,
notes: form.notes || undefined,
taxDeductible: form.taxDeductible,
@@ -138,6 +195,36 @@ export default function ExpensesPage() {
else create.mutate(payload);
};
const handleReceiptFiles = async (files: File[]) => {
if (!editId) {
toast.error("Save the expense before uploading receipts");
return;
}
for (const file of files) {
const data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
if (!base64) {
reject(new Error("Failed to read file"));
return;
}
resolve(base64);
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
await uploadReceipt.mutateAsync({
expenseId: editId,
filename: file.name,
mimeType: file.type || "application/octet-stream",
data,
});
}
};
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
const billableTotal = expenses
.filter((e) => e.billable)
@@ -161,6 +248,23 @@ export default function ExpensesPage() {
</Button>
</DashboardPageHeader>
<div className="mb-4 flex items-center gap-3">
<Label className="text-sm">Business</Label>
<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>
<div className={dashboardStatGridClass}>
<Card>
<CardContent className="p-4">
@@ -202,7 +306,6 @@ export default function ExpensesPage() {
</Card>
</div>
{/* Expenses list */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -259,6 +362,12 @@ export default function ExpensesPage() {
{expense.category}
</Badge>
)}
{(expense.receipts?.length ?? 0) > 0 && (
<Badge variant="outline" className="text-xs">
<Paperclip className="mr-1 h-3 w-3" />
{expense.receipts?.length}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-0.5 text-xs">
{new Intl.DateTimeFormat("en-US", {
@@ -266,6 +375,7 @@ export default function ExpensesPage() {
day: "numeric",
year: "numeric",
}).format(new Date(expense.date))}
{expense.business ? ` · ${expense.business.name}` : ""}
{expense.client ? ` · ${expense.client.name}` : ""}
</p>
{expense.notes && (
@@ -302,8 +412,16 @@ export default function ExpensesPage() {
</CardContent>
</Card>
{/* Add/Edit dialog */}
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) {
setEditId(null);
setForm(defaultForm);
}
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
@@ -381,6 +499,31 @@ export default function ExpensesPage() {
</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
@@ -441,6 +584,79 @@ export default function ExpensesPage() {
placeholder="Additional details…"
/>
</div>
{editId ? (
<div className="space-y-3 border-t pt-4">
<Label>Receipts</Label>
{receipts.length > 0 && (
<div className="space-y-2">
{receipts.map((receipt) => {
const isImage = receipt.mimeType.startsWith("image/");
const url = `/api/receipts/${receipt.id}`;
return (
<div
key={receipt.id}
className="flex items-center gap-3 rounded-md border p-2"
>
{isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={receipt.originalFilename}
className="h-12 w-12 rounded object-cover"
/>
) : (
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded">
<FileText className="text-muted-foreground h-6 w-6" />
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{receipt.originalFilename}
</p>
<p className="text-muted-foreground text-xs">
{formatFileSize(receipt.sizeBytes)}
</p>
</div>
<Button variant="ghost" size="sm" asChild>
<a href={url} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() =>
deleteReceipt.mutate({ id: receipt.id })
}
disabled={deleteReceipt.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
)}
<FileUpload
onFilesSelected={(files) => void handleReceiptFiles(files)}
accept={{
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"],
"application/pdf": [".pdf"],
}}
maxFiles={5}
maxSize={10 * 1024 * 1024}
disabled={uploadReceipt.isPending}
placeholder="Drop receipts here"
description="Images or PDF, up to 10MB each"
/>
</div>
) : (
<p className="text-muted-foreground text-xs">
Save the expense first, then you can attach receipts.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
@@ -460,7 +676,6 @@ export default function ExpensesPage() {
</DialogContent>
</Dialog>
{/* Delete dialog */}
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
@@ -2,6 +2,10 @@
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", {
@@ -58,8 +62,8 @@ const columns: ColumnDef<InvoiceItem>[] = [
<div className="sm:hidden">
<p className="font-medium">{item.description}</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{formatDate(item.date)} &middot; {item.hours}h @{" "}
{formatCurrency(item.rate)}/hr
{formatDate(item.date)} &middot;{" "}
{formatLineItemDetail(item.hours, item.rate, formatCurrency)}
</p>
</div>
</>
@@ -69,9 +73,12 @@ const columns: ColumnDef<InvoiceItem>[] = [
{
accessorKey: "hours",
header: "Hours",
cell: ({ row }) => (
<div className="text-right">{row.getValue("hours")}</div>
),
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",
@@ -80,9 +87,16 @@ const columns: ColumnDef<InvoiceItem>[] = [
{
accessorKey: "rate",
header: "Rate",
cell: ({ row }) => (
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
),
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",
@@ -61,6 +61,8 @@ export function PDFDownloadButton({
await generateInvoicePDF(pdfData, {
pdfTemplate: pdfSettings?.pdfTemplate,
pdfAccentColor: pdfSettings?.pdfAccentColor,
pdfFontFamily: pdfSettings?.pdfFontFamily,
pdfNumericFontFamily: pdfSettings?.pdfNumericFontFamily,
pdfFooterText: pdfSettings?.pdfFooterText,
pdfShowLogo: pdfSettings?.pdfShowLogo,
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers,
+40 -14
View File
@@ -51,10 +51,14 @@ function toNumericChartValue(value: unknown) {
}
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();
api.expenses.getAll.useQuery(
businessFilter === "all" ? undefined : { businessId: businessFilter },
);
const { data: stats } = api.dashboard.getStats.useQuery();
const isLoading = invoicesLoading || expensesLoading;
@@ -62,9 +66,14 @@ export default function ReportsPage() {
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 (!invoices.length) return null;
if (!filteredInvoices.length) return null;
const now = new Date();
const monthMap: Record<string, number> = {};
@@ -78,7 +87,7 @@ export default function ReportsPage() {
let totalPending = 0;
let totalHours = 0;
for (const inv of invoices) {
for (const inv of filteredInvoices) {
const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
@@ -102,7 +111,7 @@ export default function ReportsPage() {
}));
const clientMap: Record<string, { name: string; revenue: number }> = {};
for (const inv of invoices) {
for (const inv of filteredInvoices) {
const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
@@ -126,7 +135,7 @@ export default function ReportsPage() {
paid: 0,
overdue: 0,
};
for (const inv of invoices) {
for (const inv of filteredInvoices) {
const s = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
@@ -142,13 +151,13 @@ export default function ReportsPage() {
totalHours,
statusCount,
};
}, [invoices]);
}, [filteredInvoices]);
// Tax summary for selected year
const taxData = useMemo(() => {
const year = parseInt(taxYear);
const yearInvoices = invoices.filter((inv) => {
const yearInvoices = filteredInvoices.filter((inv) => {
const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
@@ -224,20 +233,20 @@ export default function ReportsPage() {
yearInvoices,
yearExpenses,
};
}, [invoices, expenses, taxYear]);
}, [filteredInvoices, expenses, taxYear]);
const availableYears = useMemo(() => {
const years = new Set<number>([currentYear, currentYear - 1]);
for (const inv of invoices)
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);
}, [invoices, expenses, currentYear]);
}, [filteredInvoices, expenses, currentYear]);
const avgInvoice =
invoices.length > 0
filteredInvoices.length > 0
? (overviewData?.totalRevenue ?? 0) /
(invoices.filter(
(filteredInvoices.filter(
(i) =>
getEffectiveInvoiceStatus(
i.status as StoredInvoiceStatus,
@@ -335,6 +344,23 @@ export default function ReportsPage() {
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">
@@ -573,7 +599,7 @@ export default function ReportsPage() {
<div
className="bg-primary h-full rounded-full"
style={{
width: `${invoices.length ? (count / invoices.length) * 100 : 0}%`,
width: `${filteredInvoices.length ? (count / filteredInvoices.length) * 100 : 0}%`,
}}
/>
</div>
@@ -584,7 +610,7 @@ export default function ReportsPage() {
</div>
),
)}
{invoices.length === 0 && (
{filteredInvoices.length === 0 && (
<p className="text-muted-foreground py-6 text-center text-sm">
No invoices yet.
</p>
@@ -88,7 +88,8 @@ import {
} from "~/components/ui/select";
import { useAppearance } from "~/components/providers/appearance-provider";
import { brand, colorModes } from "~/lib/branding";
import type { PdfTemplate } from "~/lib/appearance";
import type { PdfFontFamily, PdfTemplate } from "~/lib/appearance";
import { pdfFontFamilyOptions } from "~/lib/pdf-fonts";
import { ApiAccessSettings } from "./api-access-settings";
import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions";
@@ -146,6 +147,7 @@ export function SettingsContent({
const { data: session } = useAuthSession();
const [name, setName] = useState("");
const [nameInitialized, setNameInitialized] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [importData, setImportData] = useState("");
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
@@ -177,6 +179,8 @@ export function SettingsContent({
const savePdfSettings = (patch: {
pdfTemplate?: PdfTemplate;
pdfAccentColor?: string;
pdfFontFamily?: PdfFontFamily;
pdfNumericFontFamily?: PdfFontFamily;
pdfFooterText?: string;
pdfShowLogo?: boolean;
pdfShowPageNumbers?: boolean;
@@ -217,7 +221,7 @@ export function SettingsContent({
};
// Queries
const { data: profile, refetch: refetchProfile } =
const { data: profile, refetch: refetchProfile, isFetched: profileFetched } =
api.settings.getProfile.useQuery();
const isAdmin = profile?.role === "admin";
const { data: dataStats } = api.settings.getDataStats.useQuery();
@@ -405,16 +409,13 @@ export function SettingsContent({
deleteDataMutation.mutate({ confirmText: deleteConfirmText });
};
// Set initial name value when profile loads
// Set initial name value once when profile loads
React.useEffect(() => {
if (profile?.name && !name) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field.
setName(profile.name);
}
if (session?.user) {
setName(session.user.name ?? "");
}
}, [session, profile?.name, name]);
if (nameInitialized || !profileFetched) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field.
setName(profile?.name ?? session?.user?.name ?? "");
setNameInitialized(true);
}, [profile?.name, profileFetched, session?.user?.name, nameInitialized]);
// (Removed direct DOM mutation; provider handles applying preferences globally)
@@ -790,6 +791,73 @@ export function SettingsContent({
className="mt-0"
/>
</div>
<div className="space-y-2">
<Label>Body Text Font</Label>
<Select
value={pdfSettings?.pdfFontFamily ?? "sans"}
onValueChange={(value) =>
savePdfSettings({
pdfFontFamily: value as PdfFontFamily,
})
}
disabled={updatePdfSettingsMutation.isPending}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{pdfFontFamilyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
{
pdfFontFamilyOptions.find(
(option) =>
option.value ===
(pdfSettings?.pdfFontFamily ?? "sans"),
)?.description
}
</p>
</div>
<div className="space-y-2">
<Label>Numbers Font</Label>
<Select
value={pdfSettings?.pdfNumericFontFamily ?? "mono"}
onValueChange={(value) =>
savePdfSettings({
pdfNumericFontFamily: value as PdfFontFamily,
})
}
disabled={updatePdfSettingsMutation.isPending}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{pdfFontFamilyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
Used for dates, hours, rates, and totals.{" "}
{
pdfFontFamilyOptions.find(
(option) =>
option.value ===
(pdfSettings?.pdfNumericFontFamily ?? "mono"),
)?.description
}
</p>
</div>
</div>
<div className="space-y-2">
@@ -847,6 +915,9 @@ export function SettingsContent({
settings={{
pdfTemplate: pdfSettings?.pdfTemplate ?? "classic",
pdfAccentColor: pdfSettings?.pdfAccentColor ?? "#111827",
pdfFontFamily: pdfSettings?.pdfFontFamily ?? "sans",
pdfNumericFontFamily:
pdfSettings?.pdfNumericFontFamily ?? "mono",
pdfFooterText:
pdfSettings?.pdfFooterText ?? "Professional Invoicing",
pdfShowLogo: pdfSettings?.pdfShowLogo ?? true,
@@ -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>
);
}
+6 -1
View File
@@ -7,6 +7,7 @@ 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) {
@@ -136,7 +137,11 @@ function PublicInvoiceView({ token }: { token: string }) {
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 break-words">{item.description}</p>
<p className="text-gray-500">
{item.hours} hrs @ {formatCurrency(item.rate, invoice.currency ?? "USD")}/hr
{formatLineItemDetail(
item.hours,
item.rate,
(amount) => formatCurrency(amount, invoice.currency ?? "USD"),
)}
</p>
</div>
<p className="font-semibold text-gray-900 shrink-0">
+25 -5
View File
@@ -108,19 +108,26 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false);
// Fetch business data if editing
const { data: business, isLoading: isLoadingBusiness } =
api.businesses.getById.useQuery(
{ id: businessId! },
{ enabled: mode === "edit" && !!businessId },
{
enabled: mode === "edit" && !!businessId,
refetchOnWindowFocus: false,
},
);
// Fetch email configuration if editing
const { data: emailConfig, isLoading: isLoadingEmailConfig } =
api.businesses.getEmailConfig.useQuery(
{ id: businessId! },
{ enabled: mode === "edit" && !!businessId },
{
enabled: mode === "edit" && !!businessId,
refetchOnWindowFocus: false,
},
);
// Update email configuration mutation
@@ -142,9 +149,21 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
},
});
// Load business data when editing
useEffect(() => {
if (business && mode === "edit") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different business.
setInitialized(false);
setIsDirty(false);
setFormData(initialFormData);
}, [businessId]);
// Load business data once when editing (avoid overwriting unsaved changes on refetch)
useEffect(() => {
if (
business &&
mode === "edit" &&
!initialized &&
!isLoadingEmailConfig
) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
setFormData({
name: business.name,
@@ -164,8 +183,9 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
resendDomain: emailConfig?.resendDomain ?? "",
emailFromName: emailConfig?.emailFromName ?? "",
});
setInitialized(true);
}
}, [business, emailConfig, mode]);
}, [business, emailConfig, mode, initialized, isLoadingEmailConfig]);
const handleInputChange = (field: string, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
+16 -4
View File
@@ -90,12 +90,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false);
// Fetch client data if editing
const { data: client, isLoading: isLoadingClient } =
api.clients.getById.useQuery(
{ id: clientId! },
{ enabled: mode === "edit" && !!clientId },
{
enabled: mode === "edit" && !!clientId,
refetchOnWindowFocus: false,
},
);
const createClient = api.clients.create.useMutation({
@@ -118,9 +122,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
},
});
// Load client data when editing
useEffect(() => {
if (client && mode === "edit") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different client.
setInitialized(false);
setIsDirty(false);
setFormData(initialFormData);
}, [clientId]);
// Load client data once when editing (avoid overwriting unsaved changes on refetch)
useEffect(() => {
if (client && mode === "edit" && !initialized) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form.
setFormData({
name: client.name,
@@ -135,8 +146,9 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
defaultHourlyRate: client.defaultHourlyRate ?? null,
currency: client.currency ?? "USD",
});
setInitialized(true);
}
}, [client, mode]);
}, [client, mode, initialized]);
const handleInputChange = (field: string, value: string | number | null) => {
setFormData((prev) => ({ ...prev, [field]: value }));
+3 -2
View File
@@ -2,6 +2,7 @@
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { getAppUrl } from "~/lib/app-url";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
interface EmailPreviewProps {
subject: string;
@@ -54,7 +55,7 @@ export function EmailPreview({
const calculateTotal = () => {
if (!invoice?.items) return 0;
const subtotal = invoice.items.reduce(
(sum, item) => sum + item.hours * item.rate,
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
0,
);
const taxAmount = subtotal * (invoice.taxRate / 100);
@@ -83,7 +84,7 @@ export function EmailPreview({
description: item.description ?? "Service",
hours: item.hours,
rate: item.rate,
amount: item.amount ?? item.hours * item.rate,
amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate),
})) ?? [],
},
customContent: content,
@@ -33,6 +33,7 @@ import {
ChevronRight,
} from "lucide-react";
import { cn } from "~/lib/utils";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
interface InvoiceItem {
id: string;
@@ -493,7 +494,7 @@ export function InvoiceCalendarView({
Total
</span>
<span className="text-primary text-lg font-bold">
${(item.hours * item.rate).toFixed(2)}
${calculateLineItemAmount(item.hours, item.rate).toFixed(2)}
</span>
</div>
</div>
+41 -15
View File
@@ -60,6 +60,11 @@ import {
import { STATUS_OPTIONS } from "./invoice/types";
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
import type { ParsedLineItem } from "~/lib/parse-line-item";
import {
applyBillingTypeChange,
calculateLineItemAmount,
getLineItemBillingType,
} from "~/lib/invoice-line-item";
import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel";
import { CountUp } from "~/components/ui/count-up";
@@ -123,6 +128,7 @@ function createDefaultInvoiceFormData(): InvoiceFormData {
hours: 1,
rate: 0,
amount: 0,
billingType: "hourly",
},
],
};
@@ -180,6 +186,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
hours: item.hours,
rate: item.rate,
amount: item.amount,
billingType: getLineItemBillingType(item.hours),
})) || [];
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
setFormData({
@@ -206,6 +213,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
hours: 1,
rate: 0,
amount: 0,
billingType: "hourly",
},
],
});
@@ -238,7 +246,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const totals = React.useMemo(() => {
const subtotal = formData.items.reduce(
(sum, item) => sum + item.hours * item.rate,
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
0,
);
const taxAmount = (subtotal * formData.taxRate) / 100;
@@ -266,9 +274,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
items: formData.items.map((item) => ({
date: item.date,
description: item.description || "Service",
hours: item.hours,
rate: item.rate,
})),
hours: item.hours,
rate: item.rate,
amount: calculateLineItemAmount(item.hours, item.rate),
})),
}),
[formData],
);
@@ -298,6 +307,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
hours: 1,
rate: prev.defaultHourlyRate ?? 0,
amount: prev.defaultHourlyRate ?? 0,
billingType: "hourly",
},
],
}));
@@ -313,7 +323,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: parsed.description,
hours: parsed.hours ?? 1,
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
amount: (parsed.hours ?? 1) * (parsed.rate ?? prev.defaultHourlyRate ?? 0),
amount: calculateLineItemAmount(
parsed.hours ?? 1,
parsed.rate ?? prev.defaultHourlyRate ?? 0,
),
billingType: "hourly",
},
],
}));
@@ -333,14 +347,23 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
setFormData((prev) => ({
...prev,
items: prev.items.map((item, i) => {
if (i === idx) {
const updated = { ...item, [field]: value };
if (field === "hours" || field === "rate") {
updated.amount = updated.hours * updated.rate;
}
return updated;
if (i !== idx) return item;
if (field === "billingType" && (value === "hourly" || value === "fixed")) {
const next = applyBillingTypeChange(value, item);
return {
...item,
...next,
billingType: value,
};
}
return item;
const updated = { ...item, [field]: value };
if (field === "hours" || field === "rate") {
updated.amount = calculateLineItemAmount(updated.hours, updated.rate);
updated.billingType = getLineItemBillingType(updated.hours);
}
return updated;
}),
}));
};
@@ -435,7 +458,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: i.description,
hours: i.hours,
rate: i.rate,
amount: i.hours * i.rate,
amount: calculateLineItemAmount(i.hours, i.rate),
})),
};
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
@@ -771,7 +794,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<span className="text-muted-foreground">Hours</span>
<span className="font-mono text-xl font-semibold">
<CountUp
value={formData.items.reduce((s, i) => s + i.hours, 0)}
value={formData.items.reduce(
(s, i) => s + (i.hours > 0 ? i.hours : 0),
0,
)}
suffix="h"
/>
</span>
@@ -898,7 +924,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
amount: calculateLineItemAmount(item.hours, item.rate),
})),
}}
/>
+86 -22
View File
@@ -10,11 +10,23 @@ import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input";
import { cn } from "~/lib/utils";
import {
calculateLineItemAmount,
getLineItemBillingType,
type LineItemBillingType,
} from "~/lib/invoice-line-item";
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
import {
useLineItemSuggestions,
type LineItemSuggestion,
} from "~/hooks/use-line-item-suggestions";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
interface InvoiceItem {
id: string;
@@ -23,6 +35,7 @@ interface InvoiceItem {
hours: number;
rate: number;
amount: number;
billingType?: LineItemBillingType;
}
interface InvoiceLineItemsProps {
@@ -149,13 +162,21 @@ function DescriptionAutocomplete({
);
}
const LINE_ITEM_GRID =
"grid-cols-[minmax(11.5rem,auto)_minmax(160px,1fr)_76px_96px_108px_88px_28px]";
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
const isFixed = billingType === "fixed";
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
return (
<div
ref={ref}
className={cn(
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
"group hover:bg-muted/30 hidden min-h-11 items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
LINE_ITEM_GRID,
)}
>
<DatePicker
@@ -177,16 +198,36 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
disabled={readOnly}
/>
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
suffix="h"
<Select
value={billingType}
onValueChange={(value: LineItemBillingType) =>
onUpdate(index, "billingType", value)
}
disabled={readOnly}
/>
>
<SelectTrigger className="h-8 w-full px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="fixed">Fixed</SelectItem>
</SelectContent>
</Select>
{isFixed ? (
<span className="text-muted-foreground text-center text-xs"></span>
) : (
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
suffix="h"
disabled={readOnly}
/>
)}
<NumberInput
value={item.rate}
@@ -200,7 +241,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
/>
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
${(item.hours * item.rate).toFixed(2)}
${lineTotal.toFixed(2)}
</div>
{!readOnly ? (
@@ -235,6 +276,10 @@ function MobileLineItem({
onDescriptionChange,
readOnly,
}: LineItemRowProps) {
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
const isFixed = billingType === "fixed";
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
return (
<div
id={`invoice-item-${index}-mobile`}
@@ -264,16 +309,33 @@ function MobileLineItem({
inputClassName="h-8 px-2 text-xs"
disabled={readOnly}
/>
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs"
suffix="h"
<Select
value={billingType}
onValueChange={(value: LineItemBillingType) =>
onUpdate(index, "billingType", value)
}
disabled={readOnly}
/>
>
<SelectTrigger className="h-8 w-[76px] shrink-0 px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="fixed">Fixed</SelectItem>
</SelectContent>
</Select>
{!isFixed ? (
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs"
suffix="h"
disabled={readOnly}
/>
) : null}
<NumberInput
value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)}
@@ -285,7 +347,7 @@ function MobileLineItem({
disabled={readOnly}
/>
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
${(item.hours * item.rate).toFixed(2)}
${lineTotal.toFixed(2)}
</span>
{!readOnly ? (
<Button
@@ -357,6 +419,7 @@ export function InvoiceLineItems({
onUpdateItem(index, "description", s.description);
onUpdateItem(index, "hours", s.hours);
onUpdateItem(index, "rate", s.rate);
onUpdateItem(index, "billingType", "hourly");
setSuggestions([]);
setQueriedIndex(null);
}
@@ -374,9 +437,10 @@ export function InvoiceLineItems({
) : null}
<AnimatePresence>
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
<div className={cn("bg-muted/60 text-muted-foreground hidden gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid", LINE_ITEM_GRID)}>
<span>Date</span>
<span>Description</span>
<span className="text-center">Type</span>
<span className="text-center">Hours</span>
<span className="text-center">Rate</span>
<span className="text-right">Amount</span>
+3
View File
@@ -3,6 +3,8 @@ import { type RouterOutputs } from "~/trpc/react";
export type ClientType = RouterOutputs["clients"]["getAll"][number];
export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
import type { LineItemBillingType } from "~/lib/invoice-line-item";
export interface InvoiceItem {
id: string;
date: Date;
@@ -10,6 +12,7 @@ export interface InvoiceItem {
hours: number;
rate: number;
amount: number;
billingType: LineItemBillingType;
}
export interface InvoiceFormData {
@@ -30,6 +30,8 @@ const SPECIAL_SEGMENTS: Record<string, string> = {
import: "Import",
export: "Export",
dashboard: "Dashboard",
entries: "All entries",
"time-clock": "Time clock",
};
import { cn } from "~/lib/utils";
+136 -92
View File
@@ -35,6 +35,8 @@ import {
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
} from "~/lib/time-clock";
import { invoiceLabel } from "~/lib/time-entry-display";
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
const FEATURED_CLIENT_COUNT = 4;
@@ -46,26 +48,6 @@ export type TimeClockPanelProps = {
compact?: boolean;
};
function invoiceLabel(inv: {
invoicePrefix: string | null;
invoiceNumber: string;
}) {
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
}
function entryHref(entry: {
invoiceId: string | null;
clientId: string | null;
invoice?: { id: string } | null;
client?: { id: string } | null;
}): string | null {
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
const clientId = entry.clientId ?? entry.client?.id;
if (clientId) return `/dashboard/clients/${clientId}`;
return null;
}
function ClientChip({
label,
active,
@@ -145,6 +127,10 @@ export function TimeClockPanel({
const last = getLastTimeClockClientId();
if (last) ids.push(last);
if (running?.clientId && !ids.includes(running.clientId)) {
ids.unshift(running.clientId);
}
for (const entry of todayEntries ?? []) {
if (entry.clientId && !ids.includes(entry.clientId)) {
ids.push(entry.clientId);
@@ -157,7 +143,7 @@ export function TimeClockPanel({
}
return ids;
}, [clients, todayEntries]);
}, [clients, todayEntries, running]);
const visibleClients = useMemo(() => {
if (!clients?.length) return [];
@@ -191,6 +177,14 @@ export function TimeClockPanel({
onError: (e) => toast.error(e.message),
});
const updateRunning = api.timeEntries.updateRunning.useMutation({
onSuccess: () => {
void utils.timeEntries.getRunning.invalidate();
void utils.invoices.getBillable.invalidate();
},
onError: (e) => toast.error(e.message),
});
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: (data) => {
const message = describeClockOutOutcome({
@@ -227,6 +221,11 @@ export function TimeClockPanel({
});
function handleClientChange(value: string) {
if (running) {
updateRunning.mutate({ clientId: value, invoiceId: "" });
return;
}
setClientId(value);
setInvoiceId("");
setLastTimeClockClientId(value);
@@ -234,6 +233,15 @@ export function TimeClockPanel({
setRate(client?.defaultHourlyRate ?? 0);
}
function handleInvoiceChange(value: string) {
const next = value === "__none__" ? "" : value;
if (running) {
updateRunning.mutate({ invoiceId: next });
return;
}
setInvoiceId(next);
}
function resolveStartedAt(): Date | undefined {
if (startMode === "now") return undefined;
if (startMode === "pick") {
@@ -293,6 +301,8 @@ export function TimeClockPanel({
const displayRate = running ? (running.rate ?? 0) : rate;
const runningTitle = formatRunningTimerLabel(running?.description);
const activeClientId = running ? (running.clientId ?? "") : clientId;
const activeInvoiceId = running ? (running.invoiceId ?? "") : invoiceId;
return (
<div className={compact ? "space-y-4" : "space-y-6"}>
@@ -347,7 +357,7 @@ export function TimeClockPanel({
<ClientChip
key={client.id}
label={client.name}
active={clientId === client.id}
active={activeClientId === client.id}
onClick={() => handleClientChange(client.id)}
/>
))}
@@ -383,7 +393,7 @@ export function TimeClockPanel({
<Label>Invoice</Label>
<Select
value={invoiceId || "__none__"}
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
onValueChange={handleInvoiceChange}
disabled={!clientId}
>
<SelectTrigger>
@@ -485,19 +495,91 @@ export function TimeClockPanel({
</Collapsible>
</>
) : (
<div className="space-y-2">
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
<Input
id="clock-stop-note"
value={stopNote}
onChange={(e) => setStopNote(e.target.value)}
placeholder={
running?.description?.trim()
? running.description
: "Update description when you stop"
}
/>
</div>
<>
<div className="space-y-2">
<Label>Client</Label>
<div className="flex flex-wrap gap-2">
{visibleClients.map((client) => (
<ClientChip
key={client.id}
label={client.name}
active={activeClientId === client.id}
onClick={() => handleClientChange(client.id)}
/>
))}
{!showAllClients && hiddenClientCount > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
className="rounded-full"
onClick={() => setShowAllClients(true)}
>
+{hiddenClientCount} more
</Button>
) : null}
</div>
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
<Select
value={activeClientId || undefined}
onValueChange={handleClientChange}
disabled={updateRunning.isPending}
>
<SelectTrigger className="mt-1">
<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-2">
<Label>Invoice</Label>
<Select
value={activeInvoiceId || "__none__"}
onValueChange={handleInvoiceChange}
disabled={!activeClientId || updateRunning.isPending}
>
<SelectTrigger>
<SelectValue
placeholder={
activeClientId
? "Draft invoice (optional)"
: "Choose a client first"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No invoice save entry only</SelectItem>
{billableInvoices?.map((inv) => (
<SelectItem key={inv.id} value={inv.id}>
{invoiceLabel(inv)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
<Input
id="clock-stop-note"
value={stopNote}
onChange={(e) => setStopNote(e.target.value)}
placeholder={
running?.description?.trim()
? running.description
: "Update description when you stop"
}
/>
</div>
</>
)}
{running ? (
@@ -529,66 +611,28 @@ export function TimeClockPanel({
</CardContent>
</Card>
{!compact && todayEntries && todayEntries.length > 0 ? (
{!compact ? (
<Card>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Today&apos;s entries</CardTitle>
<Button variant="ghost" size="sm" className="h-8" asChild>
<Link href="/dashboard/time-clock/entries">View all entries</Link>
</Button>
</CardHeader>
<CardContent>
{todayEntries
.filter((e) => e.endedAt)
.map((entry, index, entries) => {
const href = entryHref(entry);
const isLast = index === entries.length - 1;
const rowClassName = cn(
"flex items-start justify-between gap-4 py-3",
!isLast && "border-border border-b",
);
const content = (
<>
<div className="min-w-0">
<p className="font-medium">
{formatRunningTimerLabel(entry.description)}
</p>
<p className="text-muted-foreground text-sm">
{entry.client?.name ?? "No client"}
{entry.invoice
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: entry.hours
? " · not on invoice"
: ""}
</p>
</div>
<div className="text-right text-sm">
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
{entry.rate ? (
<p className="text-muted-foreground">${entry.rate}/hr</p>
) : null}
</div>
</>
);
if (href) {
return (
<Link
key={entry.id}
href={href}
className={cn(
rowClassName,
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
)}
>
{content}
</Link>
);
}
return (
<div key={entry.id} className={rowClassName}>
{content}
</div>
);
})}
{todayEntries?.some((e) => e.endedAt) ? (
<TimeEntryList entries={todayEntries} />
) : (
<p className="text-muted-foreground py-4 text-center text-sm">
No entries today.{" "}
<Link
href="/dashboard/time-clock/entries"
className="text-primary hover:underline"
>
View history
</Link>
</p>
)}
</CardContent>
</Card>
) : null}
@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { EmptyState } from "~/components/layout/page-layout";
import { Clock, Play } from "lucide-react";
import { groupEntriesByDate } from "~/lib/time-entry-display";
import { TimeEntryRow } from "~/components/time-clock/time-entry-list";
export function TimeEntriesHistory() {
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
const completedEntries = useMemo(
() => (entries ?? []).filter((e) => e.endedAt),
[entries],
);
const grouped = useMemo(
() => groupEntriesByDate(completedEntries),
[completedEntries],
);
if (isLoading) {
return (
<Card>
<CardContent className="text-muted-foreground p-6 text-sm">
Loading entries
</CardContent>
</Card>
);
}
if (completedEntries.length === 0) {
return (
<Card>
<CardContent>
<EmptyState
icon={<Clock className="h-6 w-6" />}
title="No time entries yet"
description="Start the timer to track billable hours. Completed entries will show up here."
action={
<Button asChild>
<Link href="/dashboard/time-clock">
<Play className="mr-2 h-4 w-4" />
Start timer
</Link>
</Button>
}
className="py-16"
/>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{grouped.map((group) => (
<Card key={group.dateKey}>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">
{group.label}
</CardTitle>
</CardHeader>
<CardContent>
{group.entries.map((entry, index) => (
<TimeEntryRow
key={entry.id}
entry={entry}
isLast={index === group.entries.length - 1}
/>
))}
</CardContent>
</Card>
))}
</div>
);
}
@@ -0,0 +1,76 @@
import Link from "next/link";
import { cn } from "~/lib/utils";
import { formatRunningTimerLabel } from "~/lib/time-clock";
import { entryHref, invoiceLabel, type TimeEntryListItem } from "~/lib/time-entry-display";
export function TimeEntryRow({
entry,
isLast,
}: {
entry: TimeEntryListItem;
isLast?: boolean;
}) {
const href = entryHref(entry);
const rowClassName = cn(
"flex items-start justify-between gap-4 py-3",
!isLast && "border-border border-b",
);
const content = (
<>
<div className="min-w-0">
<p className="font-medium">{formatRunningTimerLabel(entry.description)}</p>
<p className="text-muted-foreground text-sm">
{entry.client?.name ?? "No client"}
{entry.invoice
? ` · ${invoiceLabel(entry.invoice)}`
: entry.hours
? " · not on invoice"
: ""}
</p>
</div>
<div className="text-right text-sm">
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
{entry.rate ? <p className="text-muted-foreground">${entry.rate}/hr</p> : null}
</div>
</>
);
if (href) {
return (
<Link
href={href}
className={cn(
rowClassName,
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
)}
>
{content}
</Link>
);
}
return (
<div className={rowClassName}>
{content}
</div>
);
}
export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
const completed = entries.filter((e) => e.endedAt);
if (completed.length === 0) return null;
return (
<>
{completed.map((entry, index) => (
<TimeEntryRow
key={entry.id}
entry={entry}
isLast={index === completed.length - 1}
/>
))}
</>
);
}
+11
View File
@@ -35,6 +35,12 @@ export const env = createEnv({
DB_DISABLE_SSL: optionalEnvBoolean(),
DISABLE_SIGNUPS: optionalEnvBoolean().default(true),
CRON_SECRET: z.string().optional(),
// S3-compatible object storage (optional — local .data/receipts/ fallback when unset)
S3_ENDPOINT: z.string().url().optional(),
S3_BUCKET: z.string().optional(),
S3_ACCESS_KEY: z.string().optional(),
S3_SECRET_KEY: z.string().optional(),
S3_REGION: z.string().optional(),
// SSO / Authentik (optional)
AUTHENTIK_ISSUER: z.string().url().optional(),
AUTHENTIK_CLIENT_ID: z.string().optional(),
@@ -76,6 +82,11 @@ export const env = createEnv({
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
AUTHENTIK_ORIGIN: process.env.AUTHENTIK_ORIGIN,
CRON_SECRET: process.env.CRON_SECRET,
S3_ENDPOINT: process.env.S3_ENDPOINT,
S3_BUCKET: process.env.S3_BUCKET,
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
S3_REGION: process.env.S3_REGION,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
+8
View File
@@ -1,4 +1,8 @@
import { z } from "zod";
import {
pdfFontFamilySchema,
type PdfFontFamily,
} from "~/lib/pdf-fonts";
export const colorModeValues = ["light", "dark", "system"] as const;
export const pdfTemplateValues = ["classic", "minimal"] as const;
@@ -6,6 +10,8 @@ export const pdfTemplateValues = ["classic", "minimal"] as const;
export const colorModeSchema = z.enum(colorModeValues);
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
export { pdfFontFamilySchema, type PdfFontFamily };
export type ColorMode = z.infer<typeof colorModeSchema>;
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
@@ -14,6 +20,8 @@ export const defaultColorMode: ColorMode = "system";
export const defaultPdfSettings = {
pdfTemplate: "classic" as PdfTemplate,
pdfAccentColor: "#111827",
pdfFontFamily: "sans" as PdfFontFamily,
pdfNumericFontFamily: "mono" as PdfFontFamily,
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
+28
View File
@@ -0,0 +1,28 @@
import { db } from "~/server/db";
import { auditLog } from "~/server/db/schema";
export type AuditAction =
| "user.profile_updated"
| "user.role_updated"
| "user.password_reset_sent"
| "platform.pdf_settings_updated";
export type AuditTargetType = "user" | "platform";
type LogAuditEventInput = {
actorUserId: string;
action: AuditAction;
targetType: AuditTargetType;
targetId?: string;
metadata?: Record<string, unknown>;
};
export async function logAuditEvent(input: LogAuditEventInput): Promise<void> {
await db.insert(auditLog).values({
actorUserId: input.actorUserId,
action: input.action,
targetType: input.targetType,
targetId: input.targetId,
metadata: input.metadata,
});
}
+2 -1
View File
@@ -1,11 +1,12 @@
import { env } from "~/env";
import { type ColorMode } from "~/lib/appearance";
export type { ColorMode, PdfTemplate } from "~/lib/appearance";
export type { ColorMode, PdfFontFamily, PdfTemplate } from "~/lib/appearance";
export {
colorModeSchema,
defaultColorMode,
defaultPdfSettings,
pdfFontFamilySchema,
pdfTemplateSchema,
} from "~/lib/appearance";
+38
View File
@@ -0,0 +1,38 @@
export type LineItemBillingType = "hourly" | "fixed";
export function isFixedLineItem(hours: number): boolean {
return hours === 0;
}
export function getLineItemBillingType(hours: number): LineItemBillingType {
return isFixedLineItem(hours) ? "fixed" : "hourly";
}
export function calculateLineItemAmount(hours: number, rate: number): number {
return isFixedLineItem(hours) ? rate : hours * rate;
}
export function formatLineItemDetail(
hours: number,
rate: number,
formatCurrency: (amount: number) => string,
): string {
if (isFixedLineItem(hours)) {
return "Fixed amount";
}
return `${hours}h @ ${formatCurrency(rate)}/hr`;
}
export function applyBillingTypeChange(
billingType: LineItemBillingType,
current: { hours: number; rate: number },
): { hours: number; rate: number; amount: number } {
if (billingType === "fixed") {
const amount = calculateLineItemAmount(current.hours, current.rate);
return { hours: 0, rate: amount, amount };
}
const hours = current.hours > 0 ? current.hours : 1;
const amount = calculateLineItemAmount(hours, current.rate);
return { hours, rate: current.rate, amount };
}
+3
View File
@@ -29,6 +29,9 @@ export function isNavLinkActive(pathname: string, href: string): boolean {
pathname.startsWith("/dashboard/businesses")
);
}
if (href === "/dashboard/time-clock") {
return pathname === href || pathname.startsWith("/dashboard/time-clock/");
}
return pathname === href;
}
+117
View File
@@ -0,0 +1,117 @@
import "server-only";
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
import path from "path";
// Local dev fallback when S3_* env vars are unset. Files land in .data/receipts/.
const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts");
function isS3Configured(): boolean {
return Boolean(
process.env.S3_BUCKET &&
process.env.S3_ACCESS_KEY &&
process.env.S3_SECRET_KEY,
);
}
export function getStorageBackend(): "s3" | "local" {
return isS3Configured() ? "s3" : "local";
}
type S3Module = typeof import("@aws-sdk/client-s3");
let s3ModulePromise: Promise<S3Module> | null = null;
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
async function getS3() {
if (!s3ModulePromise) {
s3ModulePromise = import("@aws-sdk/client-s3");
}
const mod = await s3ModulePromise;
if (!s3Client) {
s3Client = new mod.S3Client({
region: process.env.S3_REGION ?? "us-east-1",
endpoint: process.env.S3_ENDPOINT,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
// Required for MinIO and most S3-compatible endpoints.
forcePathStyle: Boolean(process.env.S3_ENDPOINT),
});
}
return { client: s3Client, ...mod };
}
function localPathForKey(key: string) {
return path.join(LOCAL_RECEIPTS_DIR, key);
}
export async function putObject(
key: string,
body: Buffer,
contentType: string,
): Promise<void> {
if (isS3Configured()) {
const { client, PutObjectCommand } = await getS3();
await client.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: body,
ContentType: contentType,
}),
);
return;
}
const filePath = localPathForKey(key);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, body);
}
export async function getObject(key: string): Promise<Buffer> {
if (isS3Configured()) {
const { client, GetObjectCommand } = await getS3();
const response = await client.send(
new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}),
);
const bytes = await response.Body?.transformToByteArray();
if (!bytes) {
throw new Error("Empty object body");
}
return Buffer.from(bytes);
}
return readFile(localPathForKey(key));
}
export async function deleteObject(key: string): Promise<void> {
if (isS3Configured()) {
const { client, DeleteObjectCommand } = await getS3();
await client.send(
new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}),
);
return;
}
try {
await unlink(localPathForKey(key));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
}
export const RECEIPT_MAX_BYTES = 10 * 1024 * 1024;
export function isAllowedReceiptMime(mimeType: string): boolean {
const normalized = mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
return normalized === "application/pdf" || normalized.startsWith("image/");
}
+69
View File
@@ -0,0 +1,69 @@
import crypto from "crypto";
import { eq } from "drizzle-orm";
import { Resend } from "resend";
import { env } from "~/env";
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url";
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
export type PasswordResetResult = {
success: boolean;
emailSent: boolean;
userEmail?: string;
};
export async function sendPasswordResetForUser(
userId: string,
): Promise<PasswordResetResult> {
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { id: true, email: true, name: true },
});
if (!user) {
return { success: false, emailSent: false };
}
const resetToken = crypto.randomBytes(32).toString("hex");
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
await db
.update(users)
.set({ resetToken, resetTokenExpiry })
.where(eq(users.id, user.id));
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return { success: true, emailSent: false, userEmail: user.email };
}
try {
const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: user.email,
userName: user.name ?? undefined,
resetToken,
resetUrl,
expiryHours: 24,
});
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
await resend.emails.send({
from: `beenvoice <noreply@${fromDomain}>`,
to: user.email,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text,
});
return { success: true, emailSent: true, userEmail: user.email };
} catch (emailError) {
console.error("Failed to send password reset email:", emailError);
return { success: true, emailSent: false, userEmail: user.email };
}
}
+166 -38
View File
@@ -6,9 +6,19 @@ import {
Image,
StyleSheet,
pdf,
type Styles,
} from "@react-pdf/renderer";
import { saveAs } from "file-saver";
import {
isFixedLineItem,
} from "~/lib/invoice-line-item";
import React from "react";
import {
type PdfFontFamily,
type ResolvedPdfFonts,
pdfFontCacheKey,
resolvePdfFonts,
} from "~/lib/pdf-fonts";
// Fallback download function for better browser compatibility
function downloadBlob(blob: Blob, filename: string): void {
@@ -101,6 +111,8 @@ export interface InvoiceData {
export interface PDFGenerationSettings {
pdfTemplate?: "classic" | "minimal";
pdfAccentColor?: string;
pdfFontFamily?: PdfFontFamily;
pdfNumericFontFamily?: PdfFontFamily;
pdfFooterText?: string;
pdfShowLogo?: boolean;
pdfShowPageNumbers?: boolean;
@@ -109,6 +121,8 @@ export interface PDFGenerationSettings {
const defaultPDFSettings: Required<PDFGenerationSettings> = {
pdfTemplate: "classic",
pdfAccentColor: "#111827",
pdfFontFamily: "sans",
pdfNumericFontFamily: "mono",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
@@ -118,7 +132,95 @@ function resolvePDFSettings(settings?: PDFGenerationSettings) {
return { ...defaultPDFSettings, ...settings };
}
const styles = StyleSheet.create({
function mapLegacyPdfFont(
fontFamily: string,
fonts: ResolvedPdfFonts,
): string {
switch (fontFamily) {
case "Helvetica-Bold":
return fonts.bold;
case "Helvetica":
return fonts.regular;
case "Courier-Bold":
return fonts.monoBold;
case "Courier":
return fonts.mono;
default:
return fontFamily;
}
}
function remapStyleFontFamilies<T extends Styles>(
sheet: T,
fonts: ResolvedPdfFonts,
): T {
const remapped = {} as T;
for (const [key, style] of Object.entries(sheet)) {
const fontFamily = (style as { fontFamily?: string }).fontFamily;
remapped[key as keyof T] = {
...style,
...(fontFamily
? { fontFamily: mapLegacyPdfFont(fontFamily, fonts) }
: {}),
} as T[keyof T];
}
return remapped;
}
type PdfStyleBundle = {
styles: typeof baseStyles;
minimalStyles: typeof baseMinimalStyles;
fonts: ResolvedPdfFonts;
getStatusStyle: (
status: string,
) => Array<Record<string, string | number>>;
};
const pdfStyleCache = new Map<string, PdfStyleBundle>();
function getPdfStyleBundle(
bodyFamily: PdfFontFamily,
numericFamily: PdfFontFamily,
): PdfStyleBundle {
const cacheKey = pdfFontCacheKey(bodyFamily, numericFamily);
const cached = pdfStyleCache.get(cacheKey);
if (cached) return cached;
const fonts = resolvePdfFonts(bodyFamily, numericFamily);
const styles = remapStyleFontFamilies(baseStyles, fonts);
const bundle: PdfStyleBundle = {
styles,
minimalStyles: baseMinimalStyles,
fonts,
getStatusStyle: (status: string) => {
switch (status.toLowerCase()) {
case "paid":
return [styles.statusBadge, styles.statusPaid];
case "sent":
return [styles.statusBadge, styles.statusPaid];
case "overdue":
return [
styles.statusBadge,
{ backgroundColor: "#fef2f2", color: "#dc2626" },
];
case "draft":
return [
styles.statusBadge,
{ backgroundColor: "#f9fafb", color: "#9ca3af" },
];
default:
return [styles.statusBadge, styles.statusUnpaid];
}
},
};
pdfStyleCache.set(cacheKey, bundle);
return bundle;
}
const baseStyles = StyleSheet.create({
page: {
flexDirection: "column",
backgroundColor: "#ffffff",
@@ -537,7 +639,7 @@ const styles = StyleSheet.create({
},
});
const minimalStyles = StyleSheet.create({
const baseMinimalStyles = StyleSheet.create({
page: {
fontSize: 9,
paddingTop: 28,
@@ -729,27 +831,6 @@ const getStatusLabel = (status: string) => {
}
};
const getStatusStyle = (status: string) => {
switch (status.toLowerCase()) {
case "paid":
return [styles.statusBadge, styles.statusPaid];
case "sent":
return [styles.statusBadge, styles.statusPaid];
case "overdue":
return [
styles.statusBadge,
{ backgroundColor: "#fef2f2", color: "#dc2626" },
];
case "draft":
return [
styles.statusBadge,
{ backgroundColor: "#f9fafb", color: "#9ca3af" },
];
default:
return [styles.statusBadge, styles.statusUnpaid];
}
};
function getColumnWidths(showRate: boolean) {
return showRate
? {
@@ -766,7 +847,9 @@ function getColumnWidths(showRate: boolean) {
const DenseHeader: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => {
pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => {
const { styles, minimalStyles, getStatusStyle } = pdfStyles;
const isMinimal = settings.pdfTemplate === "minimal";
return (
@@ -1029,7 +1112,9 @@ const DenseHeader: React.FC<{
const TableHeader: React.FC<{
settings: Required<PDFGenerationSettings>;
showRate: boolean;
}> = ({ settings, showRate }) => {
pdfStyles: PdfStyleBundle;
}> = ({ settings, showRate, pdfStyles }) => {
const { styles, minimalStyles } = pdfStyles;
const cols = getColumnWidths(showRate);
const isMinimal = settings.pdfTemplate === "minimal";
return (
@@ -1094,7 +1179,9 @@ const TableHeader: React.FC<{
const NotesSection: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => {
pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => {
const { styles, minimalStyles } = pdfStyles;
if (!invoice.notes) return null;
const isMinimal = settings.pdfTemplate === "minimal";
@@ -1129,9 +1216,11 @@ const NotesSection: React.FC<{
);
};
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
settings,
}) => {
const Footer: React.FC<{
settings: Required<PDFGenerationSettings>;
pdfStyles: PdfStyleBundle;
}> = ({ settings, pdfStyles }) => {
const { styles, minimalStyles, fonts } = pdfStyles;
const isMinimal = settings.pdfTemplate === "minimal";
return (
@@ -1151,7 +1240,7 @@ const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
<Text
style={{
fontSize: isMinimal ? 8 : 9,
fontFamily: "Helvetica",
fontFamily: fonts.regular,
color: "#6b7280",
marginLeft: settings.pdfShowLogo ? 8 : 0,
}}
@@ -1176,7 +1265,9 @@ const TotalsSection: React.FC<{
invoice: InvoiceData;
items: Array<NonNullable<InvoiceData["items"]>[0]>;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, items, settings }) => {
pdfStyles: PdfStyleBundle;
}> = ({ invoice, items, settings, pdfStyles }) => {
const { styles, minimalStyles, fonts } = pdfStyles;
const currency = invoice.currency ?? "USD";
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
const taxAmount = (subtotal * invoice.taxRate) / 100;
@@ -1206,7 +1297,7 @@ const TotalsSection: React.FC<{
<Text
style={{
fontSize: isMinimal ? 8 : 11,
fontFamily: "Helvetica-Bold",
fontFamily: fonts.bold,
color: "#0f0f0f",
textAlign: isMinimal ? "left" : "center",
marginBottom: isMinimal ? 5 : 8,
@@ -1301,6 +1392,26 @@ export const InvoicePDF: React.FC<{
settings?: PDFGenerationSettings;
}> = ({ invoice, settings: inputSettings }) => {
const settings = resolvePDFSettings(inputSettings);
const pdfStyles = getPdfStyleBundle(
settings.pdfFontFamily,
settings.pdfNumericFontFamily,
);
return (
<InvoicePDFDocument
invoice={invoice}
settings={settings}
pdfStyles={pdfStyles}
/>
);
};
const InvoicePDFDocument: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => {
const { styles, minimalStyles } = pdfStyles;
const items = invoice.items?.filter(Boolean) ?? [];
const currency = invoice.currency ?? "USD";
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
@@ -1313,7 +1424,11 @@ export const InvoicePDF: React.FC<{
size="LETTER"
style={[styles.page, isMinimal ? minimalStyles.page : {}]}
>
<DenseHeader invoice={invoice} settings={settings} />
<DenseHeader
invoice={invoice}
settings={settings}
pdfStyles={pdfStyles}
/>
{items.length > 0 && (
<View
@@ -1322,7 +1437,11 @@ export const InvoicePDF: React.FC<{
isMinimal ? minimalStyles.tableContainer : {},
]}
>
<TableHeader settings={settings} showRate={showRate} />
<TableHeader
settings={settings}
showRate={showRate}
pdfStyles={pdfStyles}
/>
{items.map(
(item, index) =>
item && (
@@ -1366,7 +1485,7 @@ export const InvoicePDF: React.FC<{
{ width: cols.hours },
]}
>
{item.hours}
{isFixedLineItem(item.hours) ? "—" : item.hours}
</Text>
{showRate && (
<Text
@@ -1404,12 +1523,21 @@ export const InvoicePDF: React.FC<{
wrap={false}
>
{invoice.notes && (
<NotesSection invoice={invoice} settings={settings} />
<NotesSection
invoice={invoice}
settings={settings}
pdfStyles={pdfStyles}
/>
)}
<TotalsSection invoice={invoice} items={items} settings={settings} />
<TotalsSection
invoice={invoice}
items={items}
settings={settings}
pdfStyles={pdfStyles}
/>
</View>
<Footer settings={settings} />
<Footer settings={settings} pdfStyles={pdfStyles} />
</Page>
</Document>
);
+102
View File
@@ -0,0 +1,102 @@
import { z } from "zod";
/** Built-in PDF font presets (react-pdf standard fonts, no embedding required). */
export const pdfFontFamilyValues = ["sans", "serif", "mono"] as const;
export const pdfFontFamilySchema = z.enum(pdfFontFamilyValues);
export type PdfFontFamily = z.infer<typeof pdfFontFamilySchema>;
export interface ResolvedPdfFonts {
regular: string;
bold: string;
mono: string;
monoBold: string;
}
export const pdfFontFamilyOptions: {
value: PdfFontFamily;
label: string;
description: string;
}[] = [
{
value: "sans",
label: "Modern",
description: "Clean sans-serif (Helvetica).",
},
{
value: "serif",
label: "Classic",
description: "Traditional serif (Times).",
},
{
value: "mono",
label: "Monospace",
description: "Fixed-width type (Courier).",
},
];
function resolveBodyFonts(family: PdfFontFamily): Pick<ResolvedPdfFonts, "regular" | "bold"> {
switch (family) {
case "serif":
return {
regular: "Times-Roman",
bold: "Times-Bold",
};
case "mono":
return {
regular: "Courier",
bold: "Courier-Bold",
};
case "sans":
default:
return {
regular: "Helvetica",
bold: "Helvetica-Bold",
};
}
}
function resolveNumericFonts(
family: PdfFontFamily,
): Pick<ResolvedPdfFonts, "mono" | "monoBold"> {
switch (family) {
case "serif":
return {
mono: "Times-Roman",
monoBold: "Times-Bold",
};
case "mono":
return {
mono: "Courier",
monoBold: "Courier-Bold",
};
case "sans":
default:
return {
mono: "Helvetica",
monoBold: "Helvetica-Bold",
};
}
}
export function resolvePdfFonts(
bodyFamily: PdfFontFamily,
numericFamily: PdfFontFamily = "mono",
): ResolvedPdfFonts {
return {
...resolveBodyFonts(bodyFamily),
...resolveNumericFonts(numericFamily),
};
}
export function pdfFontCacheKey(
bodyFamily: PdfFontFamily,
numericFamily: PdfFontFamily,
): string {
return `${bodyFamily}:${numericFamily}`;
}
export function isPdfFontFamily(value: unknown): value is PdfFontFamily {
return pdfFontFamilySchema.safeParse(value).success;
}
+64
View File
@@ -0,0 +1,64 @@
export function invoiceLabel(inv: {
invoicePrefix: string | null;
invoiceNumber: string;
}) {
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
}
export function entryHref(entry: {
invoiceId: string | null;
clientId: string | null;
invoice?: { id: string } | null;
client?: { id: string } | null;
}): string | null {
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
const clientId = entry.clientId ?? entry.client?.id;
if (clientId) return `/dashboard/clients/${clientId}`;
return null;
}
export type TimeEntryListItem = {
id: string;
description: string | null;
hours: number | null;
rate: number | null;
startedAt: Date;
endedAt: Date | null;
clientId: string | null;
invoiceId: string | null;
client?: { id: string; name: string } | null;
invoice?: {
id: string;
invoiceNumber: string;
invoicePrefix: string | null;
} | null;
};
export function groupEntriesByDate<T extends { startedAt: Date }>(
entries: T[],
): { dateKey: string; label: string; entries: T[] }[] {
const groups = new Map<string, T[]>();
for (const entry of entries) {
const d = new Date(entry.startedAt);
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const existing = groups.get(dateKey);
if (existing) {
existing.push(entry);
} else {
groups.set(dateKey, [entry]);
}
}
return Array.from(groups.entries()).map(([dateKey, groupEntries]) => {
const sample = new Date(groupEntries[0]!.startedAt);
const label = sample.toLocaleDateString(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
return { dateKey, label, entries: groupEntries };
});
}
+59
View File
@@ -0,0 +1,59 @@
import { desc, eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import type { db as Db } from "~/server/db";
import { businesses } from "~/server/db/schema";
type BusinessContext = {
db: typeof Db;
session: { user: { id: string } };
};
export async function verifyBusinessAccess(
ctx: BusinessContext,
businessId?: string | null,
) {
if (!businessId) return null;
const business = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, businessId),
});
if (!business) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Business not found",
});
}
if (business.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this business",
});
}
return business;
}
export async function resolveDefaultBusiness(ctx: BusinessContext) {
const [defaultBusiness] = await ctx.db
.select()
.from(businesses)
.where(eq(businesses.createdById, ctx.session.user.id))
.orderBy(desc(businesses.isDefault), desc(businesses.createdAt))
.limit(1);
return defaultBusiness ?? null;
}
/** Resolve explicit businessId or fall back to the user's default business. */
export async function resolveBusinessForExpense(
ctx: BusinessContext,
businessId?: string | null,
) {
if (businessId && businessId.trim() !== "") {
return verifyBusinessAccess(ctx, businessId);
}
return resolveDefaultBusiness(ctx);
}
+18
View File
@@ -0,0 +1,18 @@
import { eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import { users } from "~/server/db/schema";
import type { db as database } from "~/server/db";
export async function requireAdmin(ctx: {
db: typeof database;
session: { user: { id: string } };
}) {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: { role: true },
});
if (user?.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
+2
View File
@@ -10,6 +10,7 @@ import { paymentsRouter } from "~/server/api/routers/payments";
import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices";
import { apiKeysRouter } from "~/server/api/routers/apiKeys";
import { timeEntriesRouter } from "~/server/api/routers/time-entries";
import { adminRouter } from "~/server/api/routers/admin";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
export const appRouter = createTRPCRouter({
@@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
recurringInvoices: recurringInvoicesRouter,
apiKeys: apiKeysRouter,
timeEntries: timeEntriesRouter,
admin: adminRouter,
});
// export type definition of API
+287
View File
@@ -0,0 +1,287 @@
import { z } from "zod";
import { and, count, desc, eq, gte, ilike, ne, or, sql } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import { logAuditEvent } from "~/lib/audit-log";
import { sendPasswordResetForUser } from "~/lib/password-reset";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { requireAdmin } from "~/server/api/require-admin";
import {
auditLog,
businesses,
clients,
invoices,
sessions,
timeEntries,
users,
} from "~/server/db/schema";
const ACTIVE_USER_DAYS = 30;
async function assertNotLastAdmin(
db: Parameters<typeof requireAdmin>[0]["db"],
userId: string,
newRole: "user" | "admin",
) {
if (newRole === "admin") return;
const target = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { role: true },
});
if (target?.role !== "admin") return;
const [adminCount] = await db
.select({ count: count() })
.from(users)
.where(eq(users.role, "admin"));
if ((adminCount?.count ?? 0) <= 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot remove the last administrator",
});
}
}
export const adminRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => {
await requireAdmin(ctx);
const activeSince = new Date();
activeSince.setDate(activeSince.getDate() - ACTIVE_USER_DAYS);
const [
[totalUsersRow],
[activeUsersRow],
[totalInvoicesRow],
[totalBusinessesRow],
[totalClientsRow],
[totalTimeEntriesRow],
[adminCountRow],
] = await Promise.all([
ctx.db.select({ count: count() }).from(users),
ctx.db
.select({ count: sql<number>`count(distinct ${sessions.userId})::int` })
.from(sessions)
.where(gte(sessions.updatedAt, activeSince)),
ctx.db.select({ count: count() }).from(invoices),
ctx.db.select({ count: count() }).from(businesses),
ctx.db.select({ count: count() }).from(clients),
ctx.db.select({ count: count() }).from(timeEntries),
ctx.db
.select({ count: count() })
.from(users)
.where(eq(users.role, "admin")),
]);
return {
totalUsers: totalUsersRow?.count ?? 0,
activeUsers: activeUsersRow?.count ?? 0,
totalInvoices: totalInvoicesRow?.count ?? 0,
totalBusinesses: totalBusinessesRow?.count ?? 0,
totalClients: totalClientsRow?.count ?? 0,
totalTimeEntries: totalTimeEntriesRow?.count ?? 0,
adminCount: adminCountRow?.count ?? 0,
activeUserWindowDays: ACTIVE_USER_DAYS,
};
}),
listUsers: protectedProcedure
.input(
z.object({
search: z.string().optional(),
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(100).default(25),
}),
)
.query(async ({ ctx, input }) => {
await requireAdmin(ctx);
const search = input.search?.trim();
const whereClause = search
? or(
ilike(users.name, `%${search}%`),
ilike(users.email, `%${search}%`),
)
: undefined;
const [items, [totalRow]] = await Promise.all([
ctx.db.query.users.findMany({
where: whereClause,
columns: {
id: true,
name: true,
email: true,
role: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
},
orderBy: (usersTable, { asc }) => [asc(usersTable.createdAt)],
offset: input.offset,
limit: input.limit,
}),
ctx.db
.select({ count: count() })
.from(users)
.where(whereClause),
]);
return {
items,
total: totalRow?.count ?? 0,
offset: input.offset,
limit: input.limit,
};
}),
updateUser: protectedProcedure
.input(
z.object({
userId: z.string().min(1),
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
role: z.enum(["user", "admin"]),
}),
)
.mutation(async ({ ctx, input }) => {
await requireAdmin(ctx);
const existing = await ctx.db.query.users.findFirst({
where: eq(users.id, input.userId),
columns: { id: true, name: true, email: true, role: true },
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
const normalizedEmail = input.email.toLowerCase();
if (normalizedEmail !== existing.email) {
const emailTaken = await ctx.db.query.users.findFirst({
where: and(
eq(users.email, normalizedEmail),
ne(users.id, input.userId),
),
columns: { id: true },
});
if (emailTaken) {
throw new TRPCError({
code: "CONFLICT",
message: "Email is already in use",
});
}
}
await assertNotLastAdmin(ctx.db, input.userId, input.role);
const changedFields: string[] = [];
if (existing.name !== input.name) changedFields.push("name");
if (existing.email !== normalizedEmail) changedFields.push("email");
if (existing.role !== input.role) changedFields.push("role");
if (changedFields.length === 0) {
return { success: true };
}
await ctx.db
.update(users)
.set({
name: input.name,
email: normalizedEmail,
role: input.role,
})
.where(eq(users.id, input.userId));
await logAuditEvent({
actorUserId: ctx.session.user.id,
action:
changedFields.includes("role") && changedFields.length === 1
? "user.role_updated"
: "user.profile_updated",
targetType: "user",
targetId: input.userId,
metadata: {
changedFields,
...(changedFields.includes("role") && {
previousRole: existing.role,
newRole: input.role,
}),
},
});
return { success: true };
}),
sendPasswordReset: protectedProcedure
.input(z.object({ userId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await requireAdmin(ctx);
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, input.userId),
columns: { id: true },
});
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
const result = await sendPasswordResetForUser(input.userId);
await logAuditEvent({
actorUserId: ctx.session.user.id,
action: "user.password_reset_sent",
targetType: "user",
targetId: input.userId,
metadata: { emailSent: result.emailSent },
});
return {
success: result.success,
emailSent: result.emailSent,
};
}),
listAuditLog: protectedProcedure
.input(
z.object({
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(100).default(25),
}),
)
.query(async ({ ctx, input }) => {
await requireAdmin(ctx);
const [entries, [totalRow]] = await Promise.all([
ctx.db.query.auditLog.findMany({
orderBy: [desc(auditLog.createdAt)],
offset: input.offset,
limit: input.limit,
with: {
actor: {
columns: { id: true, name: true },
},
},
}),
ctx.db.select({ count: count() }).from(auditLog),
]);
return {
items: entries.map((entry) => ({
id: entry.id,
action: entry.action,
targetType: entry.targetType,
targetId: entry.targetId,
metadata: entry.metadata,
createdAt: entry.createdAt,
actor: entry.actor,
})),
total: totalRow?.count ?? 0,
offset: input.offset,
limit: input.limit,
};
}),
});
+10
View File
@@ -94,6 +94,16 @@ export const emailRouter = createTRPCRouter({
| "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,
+280 -57
View File
@@ -1,9 +1,24 @@
import { z } from "zod";
import { eq, and, desc } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
import {
expenses,
clients,
invoices,
expenseReceipts,
} from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
import {
resolveBusinessForExpense,
verifyBusinessAccess,
} from "~/server/api/lib/business-access";
import {
deleteObject,
isAllowedReceiptMime,
putObject,
RECEIPT_MAX_BYTES,
} from "~/lib/object-storage";
export { EXPENSE_CATEGORIES };
@@ -26,14 +41,108 @@ const updateExpenseSchema = createExpenseSchema.partial().extend({
id: z.string(),
});
export const expensesRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db.query.expenses.findMany({
where: eq(expenses.createdById, ctx.session.user.id),
with: { client: true, business: true, invoice: true },
orderBy: [desc(expenses.date)],
async function verifyClientAccess(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
clientId: string,
) {
const client = await ctx.db.query.clients.findFirst({
where: and(
eq(clients.id, clientId),
eq(clients.createdById, ctx.session.user.id),
),
});
if (!client) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Client not found",
});
}),
}
return client;
}
async function verifyInvoiceAccess(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
invoiceId: string,
) {
const invoice = await ctx.db.query.invoices.findFirst({
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, ctx.session.user.id),
),
});
if (!invoice) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invoice not found",
});
}
return invoice;
}
async function resolveExpenseBusinessId(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
businessId: string | null,
invoice?: { businessId: string | null } | null,
) {
const explicitBusinessId =
businessId && businessId.trim() !== "" ? businessId : null;
const inheritedBusinessId =
!explicitBusinessId && invoice?.businessId ? invoice.businessId : null;
const resolved = await resolveBusinessForExpense(
ctx,
explicitBusinessId ?? inheritedBusinessId,
);
return resolved?.id ?? null;
}
async function getOwnedExpense(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
expenseId: string,
) {
const expense = await ctx.db.query.expenses.findFirst({
where: and(
eq(expenses.id, expenseId),
eq(expenses.createdById, ctx.session.user.id),
),
});
if (!expense) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Expense not found",
});
}
return expense;
}
export const expensesRouter = createTRPCRouter({
getAll: protectedProcedure
.input(
z
.object({
businessId: z.string().optional(),
})
.optional(),
)
.query(async ({ ctx, input }) => {
const conditions = [eq(expenses.createdById, ctx.session.user.id)];
if (input?.businessId) {
await verifyBusinessAccess(ctx, input.businessId);
conditions.push(eq(expenses.businessId, input.businessId));
}
return await ctx.db.query.expenses.findMany({
where: and(...conditions),
with: {
client: true,
business: true,
invoice: true,
receipts: true,
},
orderBy: [desc(expenses.date)],
});
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
@@ -43,7 +152,12 @@ export const expensesRouter = createTRPCRouter({
eq(expenses.id, input.id),
eq(expenses.createdById, ctx.session.user.id),
),
with: { client: true, business: true, invoice: true },
with: {
client: true,
business: true,
invoice: true,
receipts: true,
},
});
if (!expense) {
@@ -69,50 +183,26 @@ export const expensesRouter = createTRPCRouter({
};
if (clean.clientId) {
const client = await ctx.db.query.clients.findFirst({
where: and(
eq(clients.id, clean.clientId),
eq(clients.createdById, ctx.session.user.id),
),
});
if (!client)
throw new TRPCError({
code: "FORBIDDEN",
message: "Client not found",
});
await verifyClientAccess(ctx, clean.clientId);
}
if (clean.businessId) {
const business = await ctx.db.query.businesses.findFirst({
where: and(
eq(businesses.id, clean.businessId),
eq(businesses.createdById, ctx.session.user.id),
),
});
if (!business)
throw new TRPCError({
code: "FORBIDDEN",
message: "Business not found",
});
}
const invoice = clean.invoiceId
? await verifyInvoiceAccess(ctx, clean.invoiceId)
: null;
if (clean.invoiceId) {
const invoice = await ctx.db.query.invoices.findFirst({
where: and(
eq(invoices.id, clean.invoiceId),
eq(invoices.createdById, ctx.session.user.id),
),
});
if (!invoice)
throw new TRPCError({
code: "FORBIDDEN",
message: "Invoice not found",
});
}
const businessId = await resolveExpenseBusinessId(
ctx,
clean.businessId,
invoice,
);
const [expense] = await ctx.db
.insert(expenses)
.values({ ...clean, createdById: ctx.session.user.id })
.values({
...clean,
businessId,
createdById: ctx.session.user.id,
})
.returning();
return expense;
@@ -137,17 +227,56 @@ export const expensesRouter = createTRPCRouter({
});
}
const clean = {
...data,
clientId: data.clientId?.trim() ?? null,
businessId: data.businessId?.trim() ?? null,
invoiceId: data.invoiceId?.trim() ?? null,
category: data.category?.trim() ?? null,
notes: data.notes?.trim() ?? null,
updatedAt: new Date(),
};
const updates: Record<string, unknown> = { updatedAt: new Date() };
await ctx.db.update(expenses).set(clean).where(eq(expenses.id, id));
if (data.date !== undefined) updates.date = data.date;
if (data.description !== undefined) updates.description = data.description;
if (data.amount !== undefined) updates.amount = data.amount;
if (data.currency !== undefined) updates.currency = data.currency;
if (data.billable !== undefined) updates.billable = data.billable;
if (data.reimbursable !== undefined) updates.reimbursable = data.reimbursable;
if (data.taxDeductible !== undefined) updates.taxDeductible = data.taxDeductible;
if (data.category !== undefined) {
updates.category = data.category?.trim() ?? null;
}
if (data.notes !== undefined) {
updates.notes = data.notes?.trim() ?? null;
}
const nextClientId =
data.clientId !== undefined ? data.clientId?.trim() || null : existing.clientId;
if (data.clientId !== undefined) {
if (nextClientId) await verifyClientAccess(ctx, nextClientId);
updates.clientId = nextClientId;
}
const nextInvoiceId =
data.invoiceId !== undefined
? data.invoiceId?.trim() || null
: existing.invoiceId;
let invoice = null;
if (data.invoiceId !== undefined) {
invoice = nextInvoiceId
? await verifyInvoiceAccess(ctx, nextInvoiceId)
: null;
updates.invoiceId = nextInvoiceId;
} else if (nextInvoiceId) {
invoice = await verifyInvoiceAccess(ctx, nextInvoiceId);
}
if (data.businessId !== undefined || data.invoiceId !== undefined) {
const nextBusinessInput =
data.businessId !== undefined
? data.businessId?.trim() || null
: existing.businessId;
updates.businessId = await resolveExpenseBusinessId(
ctx,
nextBusinessInput,
invoice,
);
}
await ctx.db.update(expenses).set(updates).where(eq(expenses.id, id));
return { success: true };
}),
@@ -160,6 +289,7 @@ export const expensesRouter = createTRPCRouter({
eq(expenses.id, input.id),
eq(expenses.createdById, ctx.session.user.id),
),
with: { receipts: true },
});
if (!existing) {
@@ -169,8 +299,101 @@ export const expensesRouter = createTRPCRouter({
});
}
await Promise.all(
existing.receipts.map((receipt) => deleteObject(receipt.storageKey)),
);
await ctx.db.delete(expenses).where(eq(expenses.id, input.id));
return { success: true };
}),
listReceipts: protectedProcedure
.input(z.object({ expenseId: z.string() }))
.query(async ({ ctx, input }) => {
await getOwnedExpense(ctx, input.expenseId);
return ctx.db.query.expenseReceipts.findMany({
where: eq(expenseReceipts.expenseId, input.expenseId),
orderBy: [desc(expenseReceipts.createdAt)],
});
}),
uploadReceipt: protectedProcedure
.input(
z.object({
expenseId: z.string(),
filename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(100),
data: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
await getOwnedExpense(ctx, input.expenseId);
if (!isAllowedReceiptMime(input.mimeType)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Only images and PDF files are allowed",
});
}
const body = Buffer.from(input.data, "base64");
if (body.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "File is empty",
});
}
if (body.length > RECEIPT_MAX_BYTES) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "File exceeds 10MB limit",
});
}
const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const storageKey = `receipts/${ctx.session.user.id}/${input.expenseId}/${crypto.randomUUID()}-${safeName}`;
await putObject(storageKey, body, input.mimeType);
const [receipt] = await ctx.db
.insert(expenseReceipts)
.values({
expenseId: input.expenseId,
storageKey,
originalFilename: input.filename,
mimeType: input.mimeType,
sizeBytes: body.length,
})
.returning();
return receipt;
}),
deleteReceipt: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const receipt = await ctx.db.query.expenseReceipts.findFirst({
where: eq(expenseReceipts.id, input.id),
with: { expense: true },
});
if (
!receipt ||
receipt.expense.createdById !== ctx.session.user.id
) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Receipt not found",
});
}
await deleteObject(receipt.storageKey);
await ctx.db
.delete(expenseReceipts)
.where(eq(expenseReceipts.id, input.id));
return { success: true };
}),
});
+42 -11
View File
@@ -9,6 +9,7 @@ import {
platformSettings,
} from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
import { Resend } from "resend";
@@ -22,12 +23,29 @@ type InvoiceRouterContext = {
session: { user: { id: string } };
};
const invoiceItemSchema = z.object({
date: z.date(),
description: z.string().min(1, "Description is required"),
hours: z.number().min(0, "Hours must be positive"),
rate: z.number().min(0, "Rate must be positive"),
});
const invoiceItemSchema = z
.object({
date: z.date(),
description: z.string().min(1, "Description is required"),
hours: z.number().min(0, "Hours must be positive"),
rate: z.number().min(0, "Rate must be positive"),
})
.superRefine((item, ctx) => {
if (item.hours === 0 && item.rate <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Fixed line items need an amount greater than zero",
path: ["rate"],
});
}
if (item.hours > 0 && item.rate <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Hourly line items need a rate greater than zero",
path: ["rate"],
});
}
});
const createInvoiceSchema = z.object({
invoiceNumber: z.string().min(1, "Invoice number is required"),
@@ -162,7 +180,10 @@ const calculateInvoiceTotal = (
items: Array<z.infer<typeof invoiceItemSchema>>,
taxRate: number,
) => {
const subtotal = items.reduce((sum, item) => sum + item.hours * item.rate, 0);
const subtotal = items.reduce(
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
0,
);
const taxAmount = (subtotal * taxRate) / 100;
return subtotal + taxAmount;
};
@@ -445,7 +466,7 @@ export const invoicesRouter = createTRPCRouter({
items.map((item, idx) => ({
...item,
invoiceId: invoice.id,
amount: item.hours * item.rate,
amount: calculateLineItemAmount(item.hours, item.rate),
position: idx,
})),
);
@@ -563,7 +584,7 @@ export const invoicesRouter = createTRPCRouter({
items.map((item, idx) => ({
...item,
invoiceId: id,
amount: item.hours * item.rate,
amount: calculateLineItemAmount(item.hours, item.rate),
position: idx,
})),
);
@@ -863,7 +884,7 @@ export const invoicesRouter = createTRPCRouter({
dbItems.map((item, idx) => ({
...item,
invoiceId: invoice.id,
amount: item.hours * item.rate,
amount: calculateLineItemAmount(item.hours, item.rate),
position: idx,
})),
);
@@ -935,7 +956,7 @@ export const invoicesRouter = createTRPCRouter({
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
amount: calculateLineItemAmount(item.hours, item.rate),
})),
},
{
@@ -944,6 +965,16 @@ export const invoicesRouter = createTRPCRouter({
| "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,
+75 -18
View File
@@ -1,12 +1,14 @@
import { z } from "zod";
import { and, eq, isNull } from "drizzle-orm";
import { and, count, eq, isNull } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs";
import { logAuditEvent } from "~/lib/audit-log";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "~/server/api/trpc";
import { requireAdmin } from "~/server/api/require-admin";
import {
accounts,
users,
@@ -26,24 +28,10 @@ import {
colorModeSchema,
defaultColorMode,
defaultPdfSettings,
pdfFontFamilySchema,
pdfTemplateSchema,
type ColorMode,
} from "~/lib/branding";
import type { db as database } from "~/server/db";
async function requireAdmin(ctx: {
db: typeof database;
session: { user: { id: string } };
}) {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: { role: true },
});
if (user?.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
function resolveBusinessId(
refs: { businessName?: string; businessNickname?: string },
@@ -237,10 +225,50 @@ export const settingsRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
await requireAdmin(ctx);
const existing = await ctx.db.query.users.findFirst({
where: eq(users.id, input.userId),
columns: { role: true },
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
if (existing.role === input.role) {
return { success: true };
}
if (existing.role === "admin" && input.role === "user") {
const [adminCount] = await ctx.db
.select({ count: count() })
.from(users)
.where(eq(users.role, "admin"));
if ((adminCount?.count ?? 0) <= 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot remove the last administrator",
});
}
}
await ctx.db
.update(users)
.set({ role: input.role })
.where(eq(users.id, input.userId));
await logAuditEvent({
actorUserId: ctx.session.user.id,
action: "user.role_updated",
targetType: "user",
targetId: input.userId,
metadata: {
previousRole: existing.role,
newRole: input.role,
},
});
return { success: true };
}),
@@ -382,6 +410,12 @@ export const settingsRouter = createTRPCRouter({
defaultPdfSettings.pdfTemplate,
pdfAccentColor:
settings?.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
pdfFontFamily:
(settings?.pdfFontFamily as "sans" | "serif" | "mono" | null) ??
defaultPdfSettings.pdfFontFamily,
pdfNumericFontFamily:
(settings?.pdfNumericFontFamily as "sans" | "serif" | "mono" | null) ??
defaultPdfSettings.pdfNumericFontFamily,
pdfFooterText:
settings?.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
@@ -395,6 +429,8 @@ export const settingsRouter = createTRPCRouter({
z.object({
pdfTemplate: pdfTemplateSchema.optional(),
pdfAccentColor: z.string().min(4).max(50).optional(),
pdfFontFamily: pdfFontFamilySchema.optional(),
pdfNumericFontFamily: pdfFontFamilySchema.optional(),
pdfFooterText: z.string().min(1).max(120).optional(),
pdfShowLogo: z.boolean().optional(),
pdfShowPageNumbers: z.boolean().optional(),
@@ -409,6 +445,11 @@ export const settingsRouter = createTRPCRouter({
pdfTemplate: input.pdfTemplate ?? defaultPdfSettings.pdfTemplate,
pdfAccentColor:
input.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
pdfFontFamily:
input.pdfFontFamily ?? defaultPdfSettings.pdfFontFamily,
pdfNumericFontFamily:
input.pdfNumericFontFamily ??
defaultPdfSettings.pdfNumericFontFamily,
pdfFooterText:
input.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
pdfShowLogo: input.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
@@ -422,6 +463,12 @@ export const settingsRouter = createTRPCRouter({
...(input.pdfAccentColor && {
pdfAccentColor: input.pdfAccentColor,
}),
...(input.pdfFontFamily && {
pdfFontFamily: input.pdfFontFamily,
}),
...(input.pdfNumericFontFamily && {
pdfNumericFontFamily: input.pdfNumericFontFamily,
}),
...(input.pdfFooterText && { pdfFooterText: input.pdfFooterText }),
...(input.pdfShowLogo !== undefined && {
pdfShowLogo: input.pdfShowLogo,
@@ -433,10 +480,20 @@ export const settingsRouter = createTRPCRouter({
},
});
await logAuditEvent({
actorUserId: ctx.session.user.id,
action: "platform.pdf_settings_updated",
targetType: "platform",
targetId: "global",
metadata: {
changedFields: Object.keys(input).filter(
(key) => input[key as keyof typeof input] !== undefined,
),
},
});
return { success: true };
}),
// Update user profile
updateProfile: protectedProcedure
.input(
z.object({
+72 -1
View File
@@ -41,6 +41,8 @@ export const platformSettings = createTable("platform_setting", (d) => ({
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(),
pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(),
pdfFontFamily: d.varchar({ length: 20 }).default("sans").notNull(),
pdfNumericFontFamily: d.varchar({ length: 20 }).default("mono").notNull(),
pdfFooterText: d
.varchar({ length: 120 })
.default("Professional Invoicing")
@@ -66,6 +68,39 @@ export const usersRelations = relations(users, ({ many }) => ({
invoiceTemplates: many(invoiceTemplates),
recurringInvoices: many(recurringInvoices),
timeEntries: many(timeEntries),
auditLogsAsActor: many(auditLog),
}));
export const auditLog = createTable(
"audit_log",
(d) => ({
id: d
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
actorUserId: d
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
action: d.varchar({ length: 100 }).notNull(),
targetType: d.varchar({ length: 50 }).notNull(),
targetId: d.varchar({ length: 255 }),
metadata: d.jsonb().$type<Record<string, unknown>>(),
createdAt: d.timestamp().notNull().defaultNow(),
}),
(t) => [
index("audit_log_actor_user_id_idx").on(t.actorUserId),
index("audit_log_action_idx").on(t.action),
index("audit_log_created_at_idx").on(t.createdAt),
],
);
export const auditLogRelations = relations(auditLog, ({ one }) => ({
actor: one(users, {
fields: [auditLog.actorUserId],
references: [users.id],
}),
}));
export const accounts = createTable(
@@ -452,12 +487,37 @@ export const expenses = createTable(
index("expense_created_by_idx").on(t.createdById),
index("expense_client_id_idx").on(t.clientId),
index("expense_invoice_id_idx").on(t.invoiceId),
index("expense_business_id_idx").on(t.businessId),
index("expense_date_idx").on(t.date),
index("expense_billable_idx").on(t.billable),
],
);
export const expensesRelations = relations(expenses, ({ one }) => ({
export const expenseReceipts = createTable(
"expense_receipt",
(d) => ({
id: d
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
expenseId: d
.varchar({ length: 255 })
.notNull()
.references(() => expenses.id, { onDelete: "cascade" }),
storageKey: d.varchar({ length: 500 }).notNull(),
originalFilename: d.varchar({ length: 255 }).notNull(),
mimeType: d.varchar({ length: 100 }).notNull(),
sizeBytes: d.integer().notNull(),
createdAt: d
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}),
(t) => [index("expense_receipt_expense_id_idx").on(t.expenseId)],
);
export const expensesRelations = relations(expenses, ({ one, many }) => ({
business: one(businesses, {
fields: [expenses.businessId],
references: [businesses.id],
@@ -474,8 +534,19 @@ export const expensesRelations = relations(expenses, ({ one }) => ({
fields: [expenses.createdById],
references: [users.id],
}),
receipts: many(expenseReceipts),
}));
export const expenseReceiptsRelations = relations(
expenseReceipts,
({ one }) => ({
expense: one(expenses, {
fields: [expenseReceipts.expenseId],
references: [expenses.id],
}),
}),
);
export const invoiceTemplates = createTable(
"invoice_template",
(d) => ({