feat: add administration page and account role management
- Implemented `AdministrationContent` component for managing account roles. - Created `AdministrationPage` to serve as the main entry point for administration tasks. - Added PDF preview functionality with `PdfPreviewFrame` component for invoice generation. - Introduced `InputColor` component for advanced color selection with various formats. - Established color conversion utilities in `color-converter.ts` for handling color formats. - Defined appearance-related schemas and types in `appearance.ts` for consistent theme management.
This commit is contained in:
@@ -35,9 +35,10 @@ export default function TermsOfServicePage() {
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none">
|
||||
<p>
|
||||
These Terms of Service ("Terms") govern your use of the
|
||||
beenvoice platform and services (the "Service") operated by
|
||||
beenvoice ("us", "we", or "our").
|
||||
These Terms of Service ("Terms") govern your use of
|
||||
the beenvoice platform and services (the "Service")
|
||||
operated by beenvoice ("us", "we", or
|
||||
"our").
|
||||
</p>
|
||||
<p>
|
||||
By accessing or using our Service, you agree to be bound by
|
||||
|
||||
@@ -29,11 +29,12 @@ function ResetPasswordForm() {
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [tokenValid, setTokenValid] = useState<boolean | null>(null);
|
||||
const [tokenValid, setTokenValid] = useState<boolean | null>(() =>
|
||||
token ? null : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setTokenValid(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,47 @@ interface InvoiceStatusChartProps {
|
||||
invoices: Invoice[];
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
draft: "hsl(0, 0%, 60%)",
|
||||
sent: "hsl(217, 91%, 60%)",
|
||||
pending: "hsl(217, 91%, 60%)",
|
||||
paid: "hsl(142, 71%, 45%)",
|
||||
overdue: "hsl(var(--destructive))",
|
||||
} as const;
|
||||
|
||||
const formatChartCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
function StatusTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
payload: { name: string; count: number; value: number };
|
||||
}>;
|
||||
}) {
|
||||
if (active && payload?.length) {
|
||||
const data = payload[0]!.payload;
|
||||
return (
|
||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||
<p className="font-medium">{data.name}</p>
|
||||
<p className="text-sm">
|
||||
{data.count} invoice{data.count !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<p className="text-sm">{formatChartCurrency(data.value)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
||||
// Process invoice data to create status breakdown
|
||||
const statusData = invoices.reduce(
|
||||
@@ -44,14 +85,6 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
||||
name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
|
||||
}));
|
||||
|
||||
// Use theme-aware colors
|
||||
const COLORS = {
|
||||
draft: "hsl(0, 0%, 60%)", // neutral grey - matches monthly metrics chart
|
||||
sent: "hsl(217, 91%, 60%)", // vibrant blue
|
||||
pending: "hsl(217, 91%, 60%)", // blue
|
||||
paid: "hsl(142, 71%, 45%)", // vibrant green
|
||||
overdue: "hsl(var(--destructive))", // red
|
||||
};
|
||||
// Animation / motion preferences
|
||||
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||
useAnimationPreferences();
|
||||
@@ -59,39 +92,6 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
||||
600 / (animationSpeedMultiplier || 1),
|
||||
);
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
payload: { name: string; count: number; value: number };
|
||||
}>;
|
||||
}) => {
|
||||
if (active && payload?.length) {
|
||||
const data = payload[0]!.payload;
|
||||
return (
|
||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||
<p className="font-medium">{data.name}</p>
|
||||
<p className="text-sm">
|
||||
{data.count} invoice{data.count !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<p className="text-sm">{formatCurrency(data.value)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
@@ -127,11 +127,13 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={COLORS[entry.status as keyof typeof COLORS]}
|
||||
fill={
|
||||
STATUS_COLORS[entry.status as keyof typeof STATUS_COLORS]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Tooltip content={<StatusTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -144,7 +146,8 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: COLORS[item.status as keyof typeof COLORS],
|
||||
backgroundColor:
|
||||
STATUS_COLORS[item.status as keyof typeof STATUS_COLORS],
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium">{item.name}</span>
|
||||
@@ -152,7 +155,7 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{item.count}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatCurrency(item.value)}
|
||||
{formatChartCurrency(item.value)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,43 @@ interface MonthlyMetricsChartProps {
|
||||
invoices: Invoice[];
|
||||
}
|
||||
|
||||
function MonthlyMetricsTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
payload: {
|
||||
paidInvoices: number;
|
||||
pendingInvoices: number;
|
||||
overdueInvoices: number;
|
||||
draftInvoices: number;
|
||||
totalInvoices: number;
|
||||
};
|
||||
}>;
|
||||
label?: string;
|
||||
}) {
|
||||
if (active && payload?.length) {
|
||||
const data = payload[0]!.payload;
|
||||
return (
|
||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||
<p className="font-medium">{label}</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
|
||||
<p className="text-primary/80">Pending: {data.pendingInvoices}</p>
|
||||
<p className="text-destructive">Overdue: {data.overdueInvoices}</p>
|
||||
<p className="text-muted-foreground">Draft: {data.draftInvoices}</p>
|
||||
<p className="text-foreground border-t pt-1 font-medium">
|
||||
Total: {data.totalInvoices}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
||||
// Process invoice data to create monthly metrics
|
||||
const monthlyData = invoices.reduce(
|
||||
@@ -95,49 +132,6 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
||||
500 / (animationSpeedMultiplier || 1),
|
||||
);
|
||||
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
payload: {
|
||||
paidInvoices: number;
|
||||
pendingInvoices: number;
|
||||
overdueInvoices: number;
|
||||
draftInvoices: number;
|
||||
totalInvoices: number;
|
||||
};
|
||||
}>;
|
||||
label?: string;
|
||||
}) => {
|
||||
if (active && payload?.length) {
|
||||
const data = payload[0]!.payload;
|
||||
return (
|
||||
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||
<p className="font-medium">{label}</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
|
||||
<p className="text-primary/80">
|
||||
Pending: {data.pendingInvoices}
|
||||
</p>
|
||||
<p className="text-destructive">
|
||||
Overdue: {data.overdueInvoices}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Draft: {data.draftInvoices}
|
||||
</p>
|
||||
<p className="text-foreground font-medium border-t pt-1">
|
||||
Total: {data.totalInvoices}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
@@ -169,7 +163,7 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Tooltip content={<MonthlyMetricsTooltip />} />
|
||||
<Bar
|
||||
dataKey="draftInvoices"
|
||||
stackId="a"
|
||||
@@ -235,9 +229,7 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
||||
<span className="text-xs">Pending</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full bg-destructive"
|
||||
/>
|
||||
<div className="bg-destructive h-3 w-3 rounded-full" />
|
||||
<span className="text-xs">Overdue</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
} from "recharts";
|
||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||
|
||||
|
||||
|
||||
interface RevenueChartProps {
|
||||
data: {
|
||||
month: string;
|
||||
@@ -91,7 +89,11 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.4} />
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(217, 91%, 60%)"
|
||||
stopOpacity={0.4}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(217, 91%, 60%)"
|
||||
|
||||
@@ -229,7 +229,7 @@ export function StatusManager({
|
||||
|
||||
{/* Overdue Warning */}
|
||||
{isOverdue && (
|
||||
<div className="bg-destructive/10 text-destructive flex items-center gap-2 p-3">
|
||||
<div className="bg-destructive/10 text-destructive flex items-center gap-2 p-3">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{daysPastDue} day{daysPastDue !== 1 ? "s" : ""} overdue
|
||||
@@ -325,7 +325,7 @@ export function StatusManager({
|
||||
|
||||
{/* No Email Warning */}
|
||||
{!clientEmail && effectiveStatus !== "paid" && (
|
||||
<div className="bg-muted text-muted-foreground p-3">
|
||||
<div className="bg-muted text-muted-foreground p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { Shield } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
Administration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Administrative access is required for this page.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
Accounts
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage account access and roles without opening customer data.
|
||||
</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>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { AdministrationContent } from "./_components/administration-content";
|
||||
|
||||
export default async function AdministrationPage() {
|
||||
return (
|
||||
<div className="page-enter space-y-6">
|
||||
<PageHeader
|
||||
title="Administration"
|
||||
description="Manage account access and platform administration"
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
||||
<AdministrationContent />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,20 +68,39 @@ export default function ExpensesPage() {
|
||||
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||
|
||||
const create = api.expenses.create.useMutation({
|
||||
onSuccess: () => { toast.success("Expense added"); void utils.expenses.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
|
||||
onSuccess: () => {
|
||||
toast.success("Expense added");
|
||||
void utils.expenses.getAll.invalidate();
|
||||
setOpen(false);
|
||||
setForm(defaultForm);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const update = api.expenses.update.useMutation({
|
||||
onSuccess: () => { toast.success("Expense updated"); void utils.expenses.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
|
||||
onSuccess: () => {
|
||||
toast.success("Expense updated");
|
||||
void utils.expenses.getAll.invalidate();
|
||||
setOpen(false);
|
||||
setEditId(null);
|
||||
setForm(defaultForm);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const del = api.expenses.delete.useMutation({
|
||||
onSuccess: () => { toast.success("Expense deleted"); void utils.expenses.getAll.invalidate(); setDeleteId(null); },
|
||||
onSuccess: () => {
|
||||
toast.success("Expense deleted");
|
||||
void utils.expenses.getAll.invalidate();
|
||||
setDeleteId(null);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleOpen = () => { setEditId(null); setForm(defaultForm); setOpen(true); };
|
||||
const handleEdit = (expense: typeof expenses[0]) => {
|
||||
const handleOpen = () => {
|
||||
setEditId(null);
|
||||
setForm(defaultForm);
|
||||
setOpen(true);
|
||||
};
|
||||
const handleEdit = (expense: (typeof expenses)[0]) => {
|
||||
setEditId(expense.id);
|
||||
setForm({
|
||||
date: new Date(expense.date),
|
||||
@@ -98,21 +117,45 @@ export default function ExpensesPage() {
|
||||
setOpen(true);
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
if (!form.description.trim()) { toast.error("Description is required"); return; }
|
||||
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; }
|
||||
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined, taxDeductible: form.taxDeductible };
|
||||
if (!form.description.trim()) {
|
||||
toast.error("Description is required");
|
||||
return;
|
||||
}
|
||||
if (form.amount <= 0) {
|
||||
toast.error("Amount must be greater than 0");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
...form,
|
||||
clientId: form.clientId || undefined,
|
||||
category: form.category || undefined,
|
||||
notes: form.notes || undefined,
|
||||
taxDeductible: form.taxDeductible,
|
||||
};
|
||||
if (editId) update.mutate({ id: editId, ...payload });
|
||||
else create.mutate(payload);
|
||||
};
|
||||
|
||||
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
||||
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0);
|
||||
const deductibleTotal = expenses.filter((e) => e.taxDeductible).reduce((s, e) => s + e.amount, 0);
|
||||
const billableTotal = expenses
|
||||
.filter((e) => e.billable)
|
||||
.reduce((s, e) => s + e.amount, 0);
|
||||
const deductibleTotal = expenses
|
||||
.filter((e) => e.taxDeductible)
|
||||
.reduce((s, e) => s + e.amount, 0);
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-6">
|
||||
<PageHeader title="Expenses" description="Track billable and non-billable expenses" variant="gradient">
|
||||
<Button onClick={handleOpen} variant="default" className="hover-lift shadow-md">
|
||||
<PageHeader
|
||||
title="Expenses"
|
||||
description="Track billable and non-billable expenses"
|
||||
variant="gradient"
|
||||
>
|
||||
<Button
|
||||
onClick={handleOpen}
|
||||
variant="default"
|
||||
className="hover-lift shadow-md"
|
||||
>
|
||||
<Plus className="mr-2 h-5 w-5" /> Add Expense
|
||||
</Button>
|
||||
</PageHeader>
|
||||
@@ -121,25 +164,39 @@ export default function ExpensesPage() {
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
|
||||
<p className="mt-1 text-2xl font-bold">{formatCurrency(totalExpenses)}</p>
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
Total
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold">
|
||||
{formatCurrency(totalExpenses)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Billable</p>
|
||||
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
Billable
|
||||
</p>
|
||||
<p className="text-primary mt-1 text-2xl font-bold">
|
||||
{formatCurrency(billableTotal)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Deductible</p>
|
||||
<p className="mt-1 text-2xl font-bold text-green-600">{formatCurrency(deductibleTotal)}</p>
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
Deductible
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold text-green-600">
|
||||
{formatCurrency(deductibleTotal)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
Count
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -154,34 +211,84 @@ export default function ExpensesPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">Loading…</div>
|
||||
<div className="text-muted-foreground p-6 text-center text-sm">
|
||||
Loading…
|
||||
</div>
|
||||
) : expenses.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
||||
<p className="text-muted-foreground text-sm">No expenses yet. Add your first expense.</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No expenses yet. Add your first expense.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{expenses.map((expense) => (
|
||||
<div key={expense.id} className="flex items-start justify-between gap-3 p-4">
|
||||
<div
|
||||
key={expense.id}
|
||||
className="flex items-start justify-between gap-3 p-4"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium">{expense.description}</p>
|
||||
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
|
||||
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
|
||||
{expense.taxDeductible && <Badge variant="outline" className="text-xs text-green-600 border-green-300">Tax Deductible</Badge>}
|
||||
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
|
||||
{expense.billable && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Billable
|
||||
</Badge>
|
||||
)}
|
||||
{expense.reimbursable && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Reimbursable
|
||||
</Badge>
|
||||
)}
|
||||
{expense.taxDeductible && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-300 text-xs text-green-600"
|
||||
>
|
||||
Tax Deductible
|
||||
</Badge>
|
||||
)}
|
||||
{expense.category && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{expense.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(expense.date))}
|
||||
{new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(expense.date))}
|
||||
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||
</p>
|
||||
{expense.notes && <p className="text-muted-foreground mt-1 text-xs">{expense.notes}</p>}
|
||||
{expense.notes && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{expense.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<p className="font-semibold">{formatCurrency(expense.amount, expense.currency)}</p>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(expense)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(expense.id)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
<p className="font-semibold">
|
||||
{formatCurrency(expense.amount, expense.currency)}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleEdit(expense)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive h-8 w-8 p-0"
|
||||
onClick={() => setDeleteId(expense.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -199,70 +306,150 @@ export default function ExpensesPage() {
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Description *</Label>
|
||||
<Input value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} placeholder="e.g. Laptop charger" />
|
||||
<Input
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, description: e.target.value }))
|
||||
}
|
||||
placeholder="e.g. Laptop charger"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Amount *</Label>
|
||||
<NumberInput value={form.amount} onChange={(v) => setForm((p) => ({ ...p, amount: v }))} min={0} step={0.01} />
|
||||
<NumberInput
|
||||
value={form.amount}
|
||||
onChange={(v) => setForm((p) => ({ ...p, amount: v }))}
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Currency</Label>
|
||||
<Select value={form.currency} onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{SUPPORTED_CURRENCIES.map((c) => <SelectItem key={c.code} value={c.code}>{c.code}</SelectItem>)}</SelectContent>
|
||||
<Select
|
||||
value={form.currency}
|
||||
onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_CURRENCIES.map((c) => (
|
||||
<SelectItem key={c.code} value={c.code}>
|
||||
{c.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Date</Label>
|
||||
<DatePicker date={form.date} onDateChange={(d) => setForm((p) => ({ ...p, date: d ?? new Date() }))} className="w-full" />
|
||||
<DatePicker
|
||||
date={form.date}
|
||||
onDateChange={(d) =>
|
||||
setForm((p) => ({ ...p, date: d ?? new Date() }))
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select value={form.category || "none"} onValueChange={(v) => setForm((p) => ({ ...p, category: v === "none" ? "" : v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
|
||||
<Select
|
||||
value={form.category || "none"}
|
||||
onValueChange={(v) =>
|
||||
setForm((p) => ({ ...p, category: v === "none" ? "" : v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{EXPENSE_CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||
{EXPENSE_CATEGORIES.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Client (optional)</Label>
|
||||
<Select value={form.clientId || "none"} onValueChange={(v) => setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="No client" /></SelectTrigger>
|
||||
<Select
|
||||
value={form.clientId || "none"}
|
||||
onValueChange={(v) =>
|
||||
setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No client</SelectItem>
|
||||
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
|
||||
<Checkbox
|
||||
checked={form.billable}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((p) => ({ ...p, billable: !!v }))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">Billable</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
|
||||
<Checkbox
|
||||
checked={form.reimbursable}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((p) => ({ ...p, reimbursable: !!v }))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">Reimbursable</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox checked={form.taxDeductible} onCheckedChange={(v) => setForm((p) => ({ ...p, taxDeductible: !!v }))} />
|
||||
<Checkbox
|
||||
checked={form.taxDeductible}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((p) => ({ ...p, taxDeductible: !!v }))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">Tax Deductible</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes (optional)</Label>
|
||||
<Input value={form.notes} onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))} placeholder="Additional details…" />
|
||||
<Input
|
||||
value={form.notes}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, notes: e.target.value }))
|
||||
}
|
||||
placeholder="Additional details…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
|
||||
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Add Expense"}
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={create.isPending || update.isPending}
|
||||
>
|
||||
{create.isPending || update.isPending
|
||||
? "Saving…"
|
||||
: editId
|
||||
? "Update"
|
||||
: "Add Expense"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -276,8 +463,14 @@ export default function ExpensesPage() {
|
||||
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteId && del.mutate({ id: deleteId })}
|
||||
disabled={del.isPending}
|
||||
>
|
||||
{del.isPending ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -53,14 +53,13 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: plain description */}
|
||||
<div className="hidden font-medium sm:block">
|
||||
{item.description}
|
||||
</div>
|
||||
<div className="hidden font-medium sm:block">{item.description}</div>
|
||||
{/* Mobile: description + date + hours @ rate stacked */}
|
||||
<div className="sm:hidden">
|
||||
<p className="font-medium">{item.description}</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{formatDate(item.date)} · {item.hours}h @ {formatCurrency(item.rate)}/hr
|
||||
{formatDate(item.date)} · {item.hours}h @{" "}
|
||||
{formatCurrency(item.rate)}/hr
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -75,7 +75,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
const handleMarkAsPaid = () => {
|
||||
updateStatus.mutate({
|
||||
id: invoiceId,
|
||||
status: "paid" as StoredInvoiceStatus,
|
||||
status: "paid",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -109,17 +109,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||
const total = subtotal + taxAmount;
|
||||
const storedStatus = invoice.status as StoredInvoiceStatus;
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
);
|
||||
const isOverdue = isInvoiceOverdue(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
storedStatus,
|
||||
invoice.dueDate,
|
||||
);
|
||||
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
|
||||
|
||||
const getStatusType = (): StatusType => {
|
||||
return effectiveStatus as StatusType;
|
||||
return effectiveStatus;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -86,7 +86,7 @@ const getStatusType = (invoice: Invoice): StatusType =>
|
||||
getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
) as StatusType;
|
||||
);
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
|
||||
@@ -29,7 +29,7 @@ function FormatInstructions() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-muted/50 p-4">
|
||||
<div className="bg-muted/50 p-4">
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
DATE,DESCRIPTION,HOURS,RATE,AMOUNT
|
||||
</p>
|
||||
@@ -85,7 +85,7 @@ function FormatInstructions() {
|
||||
for importing time entries.
|
||||
</p>
|
||||
|
||||
<div className="bg-primary/10 p-4">
|
||||
<div className="bg-primary/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="text-primary mt-0.5 h-5 w-5" />
|
||||
<div>
|
||||
@@ -100,7 +100,7 @@ function FormatInstructions() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Sample Row:</h4>
|
||||
<div className="bg-muted/50 p-3">
|
||||
<div className="bg-muted/50 p-3">
|
||||
<p className="text-muted font-mono text-xs break-all">
|
||||
1/15/24,"Web development work",8,75.00,600.00
|
||||
</p>
|
||||
@@ -109,7 +109,7 @@ function FormatInstructions() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Sample Filename:</h4>
|
||||
<div className="bg-muted/50 p-3">
|
||||
<div className="bg-muted/50 p-3">
|
||||
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,7 +168,7 @@ function FileFormatHelp() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-2 text-center">
|
||||
<div className="bg-accent mx-auto w-fit p-3">
|
||||
<div className="bg-accent mx-auto w-fit p-3">
|
||||
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
|
||||
</div>
|
||||
<h4 className="font-semibold">CSV Files</h4>
|
||||
@@ -178,7 +178,7 @@ function FileFormatHelp() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 text-center">
|
||||
<div className="bg-primary/10 mx-auto w-fit p-3">
|
||||
<div className="bg-primary/10 mx-auto w-fit p-3">
|
||||
<Upload className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<h4 className="font-semibold">Max Size</h4>
|
||||
@@ -187,7 +187,7 @@ function FileFormatHelp() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 text-center">
|
||||
<div className="bg-secondary mx-auto w-fit p-3">
|
||||
<div className="bg-secondary mx-auto w-fit p-3">
|
||||
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
|
||||
</div>
|
||||
<h4 className="font-semibold">Validation</h4>
|
||||
|
||||
@@ -28,9 +28,9 @@ import type { DashboardStats, RecentInvoice } from "./types";
|
||||
|
||||
// Hero section with clean mono design
|
||||
|
||||
|
||||
// Enhanced stats cards with better visuals
|
||||
function DashboardStats({ stats }: { stats: DashboardStats }) { // TODO: Import RouterOutput type
|
||||
function DashboardStats({ stats }: { stats: DashboardStats }) {
|
||||
// TODO: Import RouterOutput type
|
||||
const formatTrend = (value: number, isCount = false) => {
|
||||
if (isCount) {
|
||||
return value > 0 ? `+${value}` : value.toString();
|
||||
@@ -193,10 +193,11 @@ function QuickActions() {
|
||||
<Link
|
||||
key={action.title}
|
||||
href={action.href}
|
||||
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${action.featured
|
||||
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
||||
: "border-border bg-background hover:bg-muted/50"
|
||||
}`}
|
||||
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${
|
||||
action.featured
|
||||
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
||||
: "border-border bg-background hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -310,7 +311,11 @@ async function CurrentWork() {
|
||||
}
|
||||
|
||||
// Enhanced recent activity
|
||||
async function RecentActivity({ recentInvoices }: { recentInvoices: RecentInvoice[] }) {
|
||||
async function RecentActivity({
|
||||
recentInvoices,
|
||||
}: {
|
||||
recentInvoices: RecentInvoice[];
|
||||
}) {
|
||||
// Use passed recentInvoices instead of fetching all
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { BlobProvider } from "@react-pdf/renderer";
|
||||
import {
|
||||
InvoicePDF,
|
||||
type InvoiceData,
|
||||
type PDFGenerationSettings,
|
||||
} from "~/lib/pdf-export";
|
||||
|
||||
const previewInvoice: InvoiceData = {
|
||||
invoiceNumber: "BV-2026-001",
|
||||
issueDate: new Date("2026-04-30T12:00:00.000Z"),
|
||||
dueDate: new Date("2026-05-30T12:00:00.000Z"),
|
||||
status: "sent",
|
||||
totalAmount: 3150,
|
||||
taxRate: 0,
|
||||
currency: "USD",
|
||||
notes: "Thank you for the work. Payment is due within 30 days.",
|
||||
business: {
|
||||
name: "Sample Studio",
|
||||
email: "hello@beenvoice.test",
|
||||
phone: "(555) 014-1024",
|
||||
addressLine1: "100 Terminal Way",
|
||||
city: "New York",
|
||||
state: "NY",
|
||||
postalCode: "10001",
|
||||
country: "USA",
|
||||
website: "beenvoice.test",
|
||||
},
|
||||
client: {
|
||||
name: "Client Studio",
|
||||
email: "ap@clientstudio.test",
|
||||
addressLine1: "42 Market Street",
|
||||
city: "Brooklyn",
|
||||
state: "NY",
|
||||
postalCode: "11201",
|
||||
country: "USA",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
date: new Date("2026-04-08T12:00:00.000Z"),
|
||||
description: "Invoice workflow design and implementation",
|
||||
hours: 12,
|
||||
rate: 150,
|
||||
amount: 1800,
|
||||
},
|
||||
{
|
||||
date: new Date("2026-04-16T12:00:00.000Z"),
|
||||
description: "Client import cleanup",
|
||||
hours: 5,
|
||||
rate: 150,
|
||||
amount: 750,
|
||||
},
|
||||
{
|
||||
date: new Date("2026-04-24T12:00:00.000Z"),
|
||||
description: "Reporting polish",
|
||||
hours: 4,
|
||||
rate: 150,
|
||||
amount: 600,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function PdfPreviewFrame({
|
||||
settings,
|
||||
businessName,
|
||||
}: {
|
||||
settings: Required<PDFGenerationSettings>;
|
||||
businessName: string;
|
||||
}) {
|
||||
const previewBusinessName =
|
||||
businessName.trim() !== ""
|
||||
? businessName
|
||||
: (previewInvoice.business?.name ?? "Sample Studio");
|
||||
const invoice = {
|
||||
...previewInvoice,
|
||||
business: {
|
||||
...previewInvoice.business,
|
||||
name: previewBusinessName,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-muted/30 overflow-hidden border">
|
||||
<div className="bg-background flex h-10 items-center justify-between border-b px-3">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
PDF preview
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Generated from sample invoice data
|
||||
</span>
|
||||
</div>
|
||||
<BlobProvider
|
||||
document={<InvoicePDF invoice={invoice} settings={settings} />}
|
||||
>
|
||||
{({ url, loading, error }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex aspect-[8.5/11] items-center justify-center p-6 text-sm">
|
||||
Rendering PDF preview...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !url) {
|
||||
return (
|
||||
<div className="text-destructive flex aspect-[8.5/11] items-center justify-center p-6 text-sm">
|
||||
PDF preview could not be rendered.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={url}
|
||||
title="Invoice PDF preview"
|
||||
className="h-[640px] w-full bg-white"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</BlobProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Paintbrush,
|
||||
Type,
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { InputColor } from "~/components/ui/input-color";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -92,6 +94,18 @@ import {
|
||||
type InterfaceTheme,
|
||||
} from "~/lib/branding";
|
||||
|
||||
const PdfPreviewFrame = dynamic(
|
||||
() => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-muted/30 text-muted-foreground flex h-[680px] items-center justify-center border text-sm">
|
||||
Loading PDF preview...
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
function hslChannelsToHex(channels?: string) {
|
||||
const [hue, saturation, lightness] =
|
||||
channels?.match(/[\d.]+/g)?.map(Number) ?? [];
|
||||
@@ -158,6 +172,10 @@ function hexToHslChannels(hex: string) {
|
||||
)}% ${Number((lightness * 100).toFixed(1))}%`;
|
||||
}
|
||||
|
||||
function isFullHexColor(value: string) {
|
||||
return /^#[0-9A-Fa-f]{6}$/.test(value);
|
||||
}
|
||||
|
||||
export function SettingsContent() {
|
||||
const { data: session } = authClient.useSession();
|
||||
// const session = { user: null } as any;
|
||||
@@ -195,6 +213,7 @@ export function SettingsContent() {
|
||||
pdfShowLogo,
|
||||
pdfShowPageNumbers,
|
||||
updateAppearance,
|
||||
updateAppearanceDebounced,
|
||||
isUpdating: appearanceUpdating,
|
||||
} = useAppearance();
|
||||
const activePreset = themePresets[interfaceTheme];
|
||||
@@ -203,7 +222,9 @@ export function SettingsContent() {
|
||||
activePreset.headingFontPreference !== headingFontPreference ||
|
||||
activePreset.colorTheme !== colorTheme ||
|
||||
activePreset.radiusPreference !== radiusPreference ||
|
||||
activePreset.sidebarStyle !== sidebarStyle;
|
||||
activePreset.sidebarStyle !== sidebarStyle ||
|
||||
activePreset.pdfTemplate !== pdfTemplate ||
|
||||
activePreset.pdfAccentColor !== pdfAccentColor;
|
||||
const customColorValue = customColor ?? "142.1 76.2% 36.3%";
|
||||
const selectAccent = (nextColorTheme: ColorTheme) => {
|
||||
updateAppearance({
|
||||
@@ -249,10 +270,6 @@ export function SettingsContent() {
|
||||
api.settings.getProfile.useQuery();
|
||||
const isAdmin = profile?.role === "admin";
|
||||
const { data: dataStats } = api.settings.getDataStats.useQuery();
|
||||
const { data: accounts = [], refetch: refetchAccounts } =
|
||||
api.settings.listAccounts.useQuery(undefined, {
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateProfileMutation = api.settings.updateProfile.useMutation({
|
||||
@@ -321,16 +338,6 @@ export function SettingsContent() {
|
||||
toast.error(`Delete failed: ${error.message}`);
|
||||
},
|
||||
});
|
||||
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Account role updated");
|
||||
void refetchAccounts();
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
toast.error(`Failed to update role: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateProfile = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
@@ -449,6 +456,7 @@ export function SettingsContent() {
|
||||
// Set initial name value 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) {
|
||||
@@ -483,13 +491,10 @@ export function SettingsContent() {
|
||||
];
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="general" className="space-y-4">
|
||||
<TabsList
|
||||
className={`bg-muted/50 grid w-full ${isAdmin ? "grid-cols-4 lg:w-[520px]" : "grid-cols-3 lg:w-[400px]"}`}
|
||||
>
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList className="bg-muted/50 grid w-full grid-cols-3">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="preferences">Preferences</TabsTrigger>
|
||||
{isAdmin && <TabsTrigger value="admin">Admin</TabsTrigger>}
|
||||
<TabsTrigger value="data">Data</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -729,7 +734,9 @@ export function SettingsContent() {
|
||||
<Input
|
||||
value={brandName}
|
||||
onChange={(event) =>
|
||||
updateAppearance({ brandName: event.target.value })
|
||||
updateAppearanceDebounced({
|
||||
brandName: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -739,7 +746,9 @@ export function SettingsContent() {
|
||||
<Input
|
||||
value={brandLogoText}
|
||||
onChange={(event) =>
|
||||
updateAppearance({ brandLogoText: event.target.value })
|
||||
updateAppearanceDebounced({
|
||||
brandLogoText: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -749,7 +758,9 @@ export function SettingsContent() {
|
||||
<Input
|
||||
value={brandIcon}
|
||||
onChange={(event) =>
|
||||
updateAppearance({ brandIcon: event.target.value })
|
||||
updateAppearanceDebounced({
|
||||
brandIcon: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -759,7 +770,9 @@ export function SettingsContent() {
|
||||
<Input
|
||||
value={brandTagline}
|
||||
onChange={(event) =>
|
||||
updateAppearance({ brandTagline: event.target.value })
|
||||
updateAppearanceDebounced({
|
||||
brandTagline: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -826,8 +839,8 @@ export function SettingsContent() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Applies the theme, fonts, accent, corner radius, and
|
||||
navigation chrome.
|
||||
Applies the theme, fonts, accent, corner radius,
|
||||
navigation chrome, and PDF defaults.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{
|
||||
@@ -1013,32 +1026,25 @@ export function SettingsContent() {
|
||||
</button>
|
||||
</div>
|
||||
{colorTheme === "custom" && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<label className="border-input bg-background hover:bg-muted flex h-10 w-full cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-xs transition-colors sm:w-40">
|
||||
<span
|
||||
className="size-5 rounded-sm border"
|
||||
style={{
|
||||
backgroundColor: `hsl(${customColorValue})`,
|
||||
}}
|
||||
/>
|
||||
Pick color
|
||||
<input
|
||||
type="color"
|
||||
value={hslChannelsToHex(customColorValue)}
|
||||
onChange={(event) =>
|
||||
updateAppearance({
|
||||
<div className="space-y-2">
|
||||
<InputColor
|
||||
label="Custom Accent"
|
||||
value={hslChannelsToHex(customColorValue)}
|
||||
onBlur={() => undefined}
|
||||
onChange={(value) => {
|
||||
if (isFullHexColor(value)) {
|
||||
updateAppearanceDebounced({
|
||||
colorTheme: "custom",
|
||||
customColor: hexToHslChannels(event.target.value),
|
||||
})
|
||||
customColor: hexToHslChannels(value),
|
||||
});
|
||||
}
|
||||
className="sr-only"
|
||||
aria-label="Pick custom accent color"
|
||||
/>
|
||||
</label>
|
||||
}}
|
||||
className="mt-0"
|
||||
/>
|
||||
<Input
|
||||
value={customColorValue}
|
||||
onChange={(event) =>
|
||||
updateAppearance({
|
||||
updateAppearanceDebounced({
|
||||
colorTheme: "custom",
|
||||
customColor: event.target.value,
|
||||
})
|
||||
@@ -1138,119 +1144,6 @@ export function SettingsContent() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 border-t pt-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">PDF</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Controls the generated invoice PDF used for downloads and
|
||||
email attachments.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
PDF Template
|
||||
</Label>
|
||||
<Select
|
||||
value={pdfTemplate}
|
||||
onValueChange={(value) =>
|
||||
updateAppearance({
|
||||
pdfTemplate: value as typeof pdfTemplate,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="classic">Classic</SelectItem>
|
||||
<SelectItem value="minimal">Minimal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Minimal removes shaded table fills for a cleaner document.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>PDF Accent</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<label className="border-input bg-background hover:bg-muted flex h-10 w-full cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-xs transition-colors sm:w-40">
|
||||
<span
|
||||
className="size-5 rounded-sm border"
|
||||
style={{ backgroundColor: pdfAccentColor }}
|
||||
/>
|
||||
Pick color
|
||||
<input
|
||||
type="color"
|
||||
value={pdfAccentColor}
|
||||
onChange={(event) =>
|
||||
updateAppearance({
|
||||
pdfAccentColor: event.target.value,
|
||||
})
|
||||
}
|
||||
className="sr-only"
|
||||
aria-label="Pick PDF accent color"
|
||||
/>
|
||||
</label>
|
||||
<Input
|
||||
value={pdfAccentColor}
|
||||
onChange={(event) =>
|
||||
updateAppearance({
|
||||
pdfAccentColor: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="#111827"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Footer Text</Label>
|
||||
<Input
|
||||
value={pdfFooterText}
|
||||
onChange={(event) =>
|
||||
updateAppearance({ pdfFooterText: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Logo</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Include the beenvoice logo in the PDF footer.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={pdfShowLogo}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppearance({ pdfShowLogo: Boolean(checked) })
|
||||
}
|
||||
aria-label="Toggle PDF logo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Page Numbers</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Show page count in the PDF footer.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={pdfShowPageNumbers}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppearance({
|
||||
pdfShowPageNumbers: Boolean(checked),
|
||||
})
|
||||
}
|
||||
aria-label="Toggle PDF page numbers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{appearanceUpdating && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Saving appearance...
|
||||
@@ -1260,6 +1153,130 @@ export function SettingsContent() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{isAdmin && (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
Invoice Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure generated invoice PDFs and preview the real document
|
||||
output.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
PDF Template
|
||||
</Label>
|
||||
<Select
|
||||
value={pdfTemplate}
|
||||
onValueChange={(value) =>
|
||||
updateAppearance({
|
||||
pdfTemplate: value as typeof pdfTemplate,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="classic">Classic</SelectItem>
|
||||
<SelectItem value="minimal">Minimal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Minimal removes shaded table fills for a cleaner
|
||||
document.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<InputColor
|
||||
label="PDF Accent"
|
||||
value={pdfAccentColor}
|
||||
onBlur={() => undefined}
|
||||
onChange={(value) => {
|
||||
if (isFullHexColor(value)) {
|
||||
updateAppearance({
|
||||
pdfAccentColor: value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="mt-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Footer Text</Label>
|
||||
<Input
|
||||
value={pdfFooterText}
|
||||
onChange={(event) =>
|
||||
updateAppearanceDebounced({
|
||||
pdfFooterText: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||
<div className="flex items-start justify-between gap-4 border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Logo</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Include the beenvoice logo in the PDF footer.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={pdfShowLogo}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppearance({ pdfShowLogo: Boolean(checked) })
|
||||
}
|
||||
aria-label="Toggle PDF logo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Page Numbers</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Show page count in the PDF footer.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={pdfShowPageNumbers}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppearance({
|
||||
pdfShowPageNumbers: Boolean(checked),
|
||||
})
|
||||
}
|
||||
aria-label="Toggle PDF page numbers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PdfPreviewFrame
|
||||
businessName={brandName}
|
||||
settings={{
|
||||
pdfTemplate,
|
||||
pdfAccentColor,
|
||||
pdfFooterText,
|
||||
pdfShowLogo,
|
||||
pdfShowPageNumbers,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Accessibility & Animation */}
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
@@ -1357,57 +1374,6 @@ export function SettingsContent() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{isAdmin && (
|
||||
<TabsContent value="admin" className="space-y-8">
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
Accounts
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage account access and roles without opening customer data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="border-border flex flex-col gap-3 rounded-lg 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>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="data" className="space-y-8">
|
||||
{/* Data Overview */}
|
||||
<Card className="form-section bg-card border-border border">
|
||||
|
||||
@@ -3,7 +3,6 @@ import { HydrateClient } from "~/trpc/server";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
import { SettingsContent } from "./_components/settings-content";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
return (
|
||||
@@ -14,15 +13,11 @@ export default async function SettingsPage() {
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
||||
<SettingsContent />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
||||
<SettingsContent />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+28
-15
@@ -1,7 +1,7 @@
|
||||
import "~/styles/globals.css";
|
||||
|
||||
import { type Metadata } from "next";
|
||||
import { Inter, Playfair_Display, Geist_Mono } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
@@ -10,7 +10,6 @@ import { AppearanceProvider } from "~/components/providers/appearance-provider";
|
||||
import {
|
||||
brand,
|
||||
defaultBodyFontPreference,
|
||||
defaultFontPreference,
|
||||
defaultHeadingFontPreference,
|
||||
defaultInterfaceTheme,
|
||||
defaultRadiusPreference,
|
||||
@@ -25,20 +24,37 @@ export const metadata: Metadata = {
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
};
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
const geistSans = localFont({
|
||||
src: "../../public/fonts/geist/sans/Geist-VariableFont_wght.ttf",
|
||||
variable: "--font-geist-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
subsets: ["latin"],
|
||||
const playfair = localFont({
|
||||
src: "../../node_modules/@fontsource-variable/playfair-display/files/playfair-display-latin-wght-normal.woff2",
|
||||
variable: "--font-playfair",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
const frutiger = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../../public/fonts/frutiger/Frutiger.ttf",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../../public/fonts/frutiger/Frutiger_bold.ttf",
|
||||
weight: "700",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
variable: "--font-frutiger",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = localFont({
|
||||
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
|
||||
variable: "--font-geist-mono",
|
||||
display: "swap",
|
||||
});
|
||||
@@ -51,14 +67,13 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
lang="en"
|
||||
data-interface-theme={defaultInterfaceTheme}
|
||||
data-font={defaultFontPreference}
|
||||
data-body-font={defaultBodyFontPreference}
|
||||
data-heading-font={defaultHeadingFontPreference}
|
||||
data-radius={defaultRadiusPreference}
|
||||
data-sidebar-style={defaultSidebarStyle}
|
||||
data-color-mode="system"
|
||||
data-color-theme="slate"
|
||||
className={`${inter.variable} ${playfair.variable} ${geistMono.variable}`}
|
||||
className={`${geistSans.variable} ${playfair.variable} ${frutiger.variable} ${geistMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
@@ -68,7 +83,6 @@ export default function RootLayout({
|
||||
try {
|
||||
var defaults = {
|
||||
interfaceTheme: "${defaultInterfaceTheme}",
|
||||
fontPreference: "${defaultFontPreference}",
|
||||
bodyFontPreference: "${defaultBodyFontPreference}",
|
||||
headingFontPreference: "${defaultHeadingFontPreference}",
|
||||
radiusPreference: "${defaultRadiusPreference}",
|
||||
@@ -80,9 +94,8 @@ export default function RootLayout({
|
||||
var appearance = Object.assign(defaults, stored);
|
||||
var root = document.documentElement;
|
||||
root.dataset.interfaceTheme = appearance.interfaceTheme;
|
||||
root.dataset.font = appearance.fontPreference;
|
||||
root.dataset.bodyFont = appearance.bodyFontPreference || appearance.fontPreference;
|
||||
root.dataset.headingFont = appearance.headingFontPreference || appearance.fontPreference;
|
||||
root.dataset.bodyFont = appearance.bodyFontPreference;
|
||||
root.dataset.headingFont = appearance.headingFontPreference;
|
||||
root.dataset.radius = appearance.radiusPreference;
|
||||
root.dataset.sidebarStyle = appearance.sidebarStyle;
|
||||
root.dataset.colorMode = appearance.colorMode;
|
||||
|
||||
+73
-259
@@ -1,294 +1,108 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ArrowRight, FileText, UserRound } from "lucide-react";
|
||||
import { AuthRedirect } from "~/components/AuthRedirect";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Zap,
|
||||
Shield,
|
||||
BarChart3,
|
||||
Rocket,
|
||||
} from "lucide-react";
|
||||
import { brand } from "~/lib/branding";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { env } from "~/env";
|
||||
import { brand } from "~/lib/branding";
|
||||
|
||||
export default function HomePage() {
|
||||
const allowRegistration = env.DISABLE_SIGNUPS !== true;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-x-hidden">
|
||||
<main className="bg-background text-foreground min-h-screen">
|
||||
<AuthRedirect />
|
||||
|
||||
{/* Blob Background for Homepage */}
|
||||
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
|
||||
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
|
||||
</div>
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-5 py-5 sm:px-6 lg:px-8">
|
||||
<header className="flex items-center justify-between gap-4 border-b py-4">
|
||||
<Logo animated={false} />
|
||||
<nav className="flex items-center gap-2">
|
||||
<Link href="/auth/signin">
|
||||
<Button variant="ghost" size="sm">
|
||||
Sign in
|
||||
</Button>
|
||||
</Link>
|
||||
{allowRegistration && (
|
||||
<Link href="/auth/register">
|
||||
<Button size="sm">Create account</Button>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="border-border/60 bg-background/80 fixed top-4 right-4 left-4 z-50 m-4 rounded-2xl border backdrop-blur-md">
|
||||
<div className="mx-auto px-6">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<Logo />
|
||||
<div className="hidden items-center space-x-8 md:flex">
|
||||
<a
|
||||
href="#features"
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
||||
>
|
||||
Features
|
||||
</a>
|
||||
<a
|
||||
href="#pricing"
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</a>
|
||||
<section className="grid flex-1 items-center gap-10 py-14 md:grid-cols-[1fr_320px] md:py-20">
|
||||
<div className="max-w-2xl space-y-7">
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Personal invoicing
|
||||
</p>
|
||||
<h1 className="font-heading text-4xl leading-tight font-bold tracking-normal sm:text-5xl">
|
||||
{brand.name} is a place to make and track invoices.
|
||||
</h1>
|
||||
<p className="text-muted-foreground max-w-xl text-base leading-7 sm:text-lg">
|
||||
Built for one person managing real clients, real work, and the
|
||||
small admin loop around getting paid.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Link href="/auth/signin">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Sign In
|
||||
<Button size="lg" className="h-11 px-5">
|
||||
Open workspace
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{allowRegistration && (
|
||||
<Link href="/auth/register">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="rounded-xl px-6"
|
||||
>
|
||||
Get Started
|
||||
<Button variant="outline" size="lg" className="h-11 px-5">
|
||||
Create account
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-48 pb-32">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Badge className="bg-primary/10 text-primary border-primary/20 mb-8 rounded-full border px-4 py-1 text-sm">
|
||||
<Zap className="mr-2 h-3.5 w-3.5" />
|
||||
Completely Free for Everyone
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-foreground font-heading mb-8 text-6xl leading-tight font-bold tracking-tight sm:text-7xl lg:text-8xl">
|
||||
{brand.name} <br />
|
||||
<span className="text-primary italic">Beautifully Simple.</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mx-auto mb-12 max-w-2xl font-sans text-xl leading-relaxed">
|
||||
{brand.tagline}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
|
||||
{allowRegistration && (
|
||||
<Link href="/auth/register">
|
||||
<Button
|
||||
size="lg"
|
||||
className="shadow-primary/20 hover:shadow-primary/30 h-14 rounded-2xl px-10 text-lg shadow-xl transition-all duration-300 hover:shadow-2xl"
|
||||
>
|
||||
Start For Free
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<a href="#features">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="border-border/50 bg-background/50 hover:bg-background/80 h-14 rounded-2xl px-10 text-lg backdrop-blur-sm"
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground/80 mt-16 flex flex-col items-center justify-center gap-2 text-sm sm:flex-row sm:gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="text-primary h-4 w-4" />
|
||||
<span>No credit card required</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="text-primary h-4 w-4" />
|
||||
<span>Setup in 2 minutes</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="text-primary h-4 w-4" />
|
||||
<span>Free forever</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section id="features" className="relative py-24">
|
||||
<div className="relative z-10 container mx-auto px-4">
|
||||
<div className="mb-20 text-center">
|
||||
<h2 className="text-foreground font-heading mb-6 text-4xl font-bold sm:text-5xl">
|
||||
Everything you need to{" "}
|
||||
<span className="text-primary italic">thrive</span>
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg">
|
||||
Powerful features wrapped in a calm, focused interface.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
icon: Rocket,
|
||||
title: "Quick Setup",
|
||||
description:
|
||||
"Start creating invoices immediately. No complicated setup required.",
|
||||
items: [
|
||||
"Simple client management",
|
||||
"Professional templates",
|
||||
"Easy invoice sending",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: "Payment Tracking",
|
||||
description:
|
||||
"Keep track of invoice status and monitor your payments effortlessly.",
|
||||
items: [
|
||||
"Invoice status tracking",
|
||||
"Payment history",
|
||||
"Overdue notifications",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Professional Features",
|
||||
description:
|
||||
"Tools that make you look professional and get you paid faster.",
|
||||
items: [
|
||||
"PDF generation",
|
||||
"Custom tax rates",
|
||||
"Professional numbering",
|
||||
],
|
||||
},
|
||||
].map((feature, i) => (
|
||||
<Card
|
||||
key={i}
|
||||
className="group border-border/40 bg-background/60 backdrop-blur-xl transition-transform duration-500 hover:-translate-y-2"
|
||||
>
|
||||
<CardContent className="p-8">
|
||||
<div className="bg-primary/10 text-primary mb-6 inline-flex rounded-2xl p-4">
|
||||
<feature.icon className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-foreground font-heading mb-4 text-2xl font-bold">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
{feature.description}
|
||||
<div className="border-border bg-card text-card-foreground rounded-xl border p-5 shadow-sm">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 text-primary rounded-md p-2">
|
||||
<UserRound className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Clients</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm leading-6">
|
||||
Keep the people and businesses you invoice in one place.
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{feature.items.map((item, j) => (
|
||||
<li
|
||||
key={j}
|
||||
className="text-foreground/80 flex items-center gap-3 text-sm"
|
||||
>
|
||||
<div className="bg-primary h-1.5 w-1.5 rounded-full" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<section id="pricing" className="relative overflow-hidden py-24">
|
||||
<div className="relative z-10 container mx-auto px-4">
|
||||
<div className="mx-auto mb-16 max-w-4xl text-center">
|
||||
<h2 className="font-heading mb-6 text-5xl font-bold">
|
||||
Simple Pricing
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xl">
|
||||
Focus on your work, not on fees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-md">
|
||||
<Card className="border-primary/50 shadow-primary/5 bg-background/80 relative overflow-visible shadow-2xl backdrop-blur-xl">
|
||||
<div className="bg-primary text-primary-foreground absolute -top-4 left-1/2 -translate-x-1/2 rounded-full px-6 py-1.5 text-sm font-medium shadow-lg">
|
||||
Forever Free
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="p-10 text-center">
|
||||
<div className="font-heading mb-2 text-6xl font-bold">$0</div>
|
||||
<div className="text-muted-foreground mb-8">
|
||||
No credit card required.
|
||||
|
||||
<div className="flex items-start gap-3 border-t pt-5">
|
||||
<div className="bg-primary/10 text-primary rounded-md p-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<div className="mb-10 space-y-4 pl-8 text-left">
|
||||
{[
|
||||
"Unlimited Invoices",
|
||||
"Unlimited Clients",
|
||||
"PDF Downloads",
|
||||
"Payment Tracking",
|
||||
"Email Support",
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Check className="text-primary h-5 w-5 shrink-0" />
|
||||
<span className="text-foreground/90">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Invoices</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm leading-6">
|
||||
Draft, send, mark paid, and export the PDF when you need it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{allowRegistration && (
|
||||
<Link href="/auth/register" className="block">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-12 w-full rounded-xl text-lg"
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-border/40 bg-background/50 mt-12 border-t py-12 backdrop-blur-sm">
|
||||
<div className="container mx-auto flex flex-col items-center justify-between gap-6 px-6 md:flex-row">
|
||||
<div className="flex items-center gap-3">
|
||||
<Logo size="sm" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
© 2024 beenvoice
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex gap-8 text-sm">
|
||||
<a href="#" className="hover:text-foreground transition-colors">
|
||||
<footer className="text-muted-foreground flex flex-col gap-3 border-t py-5 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>© 2026 {brand.name}</span>
|
||||
<div className="flex gap-5">
|
||||
<Link href="/privacy" className="hover:text-foreground">
|
||||
Privacy
|
||||
</a>
|
||||
<a href="#" className="hover:text-foreground transition-colors">
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-foreground">
|
||||
Terms
|
||||
</a>
|
||||
<a href="#" className="hover:text-foreground transition-colors">
|
||||
Contact
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user