mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 17:48:55 -04:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user