add receipts support
This commit is contained in:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} · {item.hours}h @{" "}
|
||||
{formatCurrency(item.rate)}/hr
|
||||
{formatDate(item.date)} ·{" "}
|
||||
{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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user