feat: improve invoice view responsiveness and settings UX

- Replace custom invoice items table with responsive DataTable component
- Fix server/client component error by creating InvoiceItemsTable client
  component
- Merge danger zone with actions sidebar and use destructive button
  variant
- Standardize button text sizing across all action buttons
- Remove false claims from homepage (testimonials, ratings, fake user
  counts)
- Focus homepage messaging on freelancers with honest feature
  descriptions
- Fix dark mode support throughout app by replacing hard-coded colors
  with semantic classes
- Remove aggressive red styling from settings, add subtle red accents
  only
- Align import/export buttons and improve delete confirmation UX
- Update dark mode background to have subtle green tint instead of pure
  black
- Fix HTML nesting error in AlertDialog by using div instead of nested p
  tags

This update makes the invoice view properly responsive, removes
misleading marketing claims, and ensures consistent dark mode support
across the entire application.
This commit is contained in:
2025-07-15 02:35:55 -04:00
parent f331136090
commit c9a664869c
71 changed files with 2795 additions and 3043 deletions

View File

@@ -8,7 +8,7 @@ import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/logo";
import { Logo } from "~/components/branding/logo";
import { User, Mail, Lock, ArrowRight } from "lucide-react";
function RegisterForm() {

View File

@@ -9,7 +9,7 @@ import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/logo";
import { Logo } from "~/components/branding/logo";
import { Mail, Lock, ArrowRight } from "lucide-react";
function SignInForm() {

View File

@@ -1,7 +1,7 @@
import { auth } from "~/server/auth";
import { HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { ClientForm } from "~/components/client-form";
import { ClientForm } from "~/components/forms/client-form";
import Link from "next/link";
interface EditClientPageProps {

View File

@@ -1,5 +1,5 @@
import { Navbar } from "~/components/Navbar";
import { Sidebar } from "~/components/Sidebar";
import { Navbar } from "~/components/layout/navbar";
import { Sidebar } from "~/components/layout/sidebar";
export default function ClientsLayout({
children,
@@ -17,4 +17,4 @@ export default function ClientsLayout({
</div>
</>
);
}
}

View File

@@ -1,7 +1,7 @@
import { auth } from "~/server/auth";
import { HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { ClientForm } from "~/components/client-form";
import { ClientForm } from "~/components/forms/client-form";
import Link from "next/link";
export default async function NewClientPage() {
@@ -34,4 +34,4 @@ export default async function NewClientPage() {
</div>
</HydrateClient>
);
}
}

View File

@@ -2,7 +2,7 @@ import Link from "next/link";
import { auth } from "~/server/auth";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { ClientList } from "~/components/client-list";
import { ClientList } from "~/components/data/client-list";
import { Plus } from "lucide-react";
export default async function ClientsPage() {
@@ -39,4 +39,4 @@ export default async function ClientsPage() {
</div>
</HydrateClient>
);
}
}

View File

@@ -1,263 +0,0 @@
"use client";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
import {
Users,
FileText,
TrendingUp,
Calendar,
Plus,
ArrowRight,
} from "lucide-react";
import Link from "next/link";
import {
DashboardStatsSkeleton,
DashboardActivitySkeleton,
} from "~/components/ui/skeleton";
// Client component for dashboard stats
export function DashboardStats() {
const { data: clients, isLoading: clientsLoading } =
api.clients.getAll.useQuery();
const { data: invoices, isLoading: invoicesLoading } =
api.invoices.getAll.useQuery();
if (clientsLoading || invoicesLoading) {
return <DashboardStatsSkeleton />;
}
const totalClients = clients?.length ?? 0;
const totalInvoices = invoices?.length ?? 0;
const totalRevenue =
invoices?.reduce((sum, invoice) => sum + invoice.totalAmount, 0) ?? 0;
const pendingInvoices =
invoices?.filter(
(invoice) => invoice.status === "sent" || invoice.status === "draft",
).length ?? 0;
// Calculate month-over-month changes (simplified)
const lastMonthClients = 0; // This would need historical data
const lastMonthInvoices = 0;
const lastMonthRevenue = 0;
return (
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<Card className="shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">
Total Clients
</CardTitle>
<div className="rounded-lg bg-emerald-100 p-2">
<Users className="h-4 w-4 text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-emerald-600">
{totalClients}
</div>
<p className="text-muted-foreground text-xs">
{totalClients > lastMonthClients ? "+" : ""}
{totalClients - lastMonthClients} from last month
</p>
</CardContent>
</Card>
<Card className="shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">
Total Invoices
</CardTitle>
<div className="rounded-lg bg-blue-100 p-2">
<FileText className="h-4 w-4 text-blue-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-blue-600">
{totalInvoices}
</div>
<p className="text-muted-foreground text-xs">
{totalInvoices > lastMonthInvoices ? "+" : ""}
{totalInvoices - lastMonthInvoices} from last month
</p>
</CardContent>
</Card>
<Card className="shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">
Revenue
</CardTitle>
<div className="rounded-lg bg-teal-100 p-2">
<TrendingUp className="h-4 w-4 text-teal-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-teal-600">
${totalRevenue.toFixed(2)}
</div>
<p className="text-muted-foreground text-xs">
{totalRevenue > lastMonthRevenue ? "+" : ""}
{(
((totalRevenue - lastMonthRevenue) / (lastMonthRevenue || 1)) *
100
).toFixed(1)}
% from last month
</p>
</CardContent>
</Card>
<Card className="shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">
Pending Invoices
</CardTitle>
<div className="rounded-lg bg-orange-100 p-2">
<Calendar className="h-4 w-4 text-orange-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-orange-600">
{pendingInvoices}
</div>
<p className="text-muted-foreground text-xs">Due this month</p>
</CardContent>
</Card>
</div>
);
}
// Client component for dashboard cards
export function DashboardCards() {
return (
<div className="mb-8 grid grid-cols-1 gap-8 lg:grid-cols-2">
<Card className="shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-700">
<div className="rounded-lg bg-emerald-100 p-2">
<Users className="h-5 w-5" />
</div>
Manage Clients
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
Add new clients and manage your existing client relationships.
</p>
<div className="flex gap-3">
<Button asChild variant="brand">
<Link href="/dashboard/clients/new">
<Plus className="mr-2 h-4 w-4" />
Add Client
</Link>
</Button>
<Button variant="outline" asChild className="font-medium">
<Link href="/dashboard/clients">
View All Clients
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
<Card className="shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-700">
<div className="rounded-lg bg-emerald-100 p-2">
<FileText className="h-5 w-5" />
</div>
Create Invoices
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
Generate professional invoices and track payments.
</p>
<div className="flex gap-3">
<Button asChild variant="brand">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
New Invoice
</Link>
</Button>
<Button variant="outline" asChild className="font-medium">
<Link href="/dashboard/invoices">
View All Invoices
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
// Client component for recent activity
export function DashboardActivity() {
const { data: invoices, isLoading } = api.invoices.getAll.useQuery();
if (isLoading) {
return <DashboardActivitySkeleton />;
}
const recentInvoices = invoices?.slice(0, 5) ?? [];
return (
<Card className="shadow-xl backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-emerald-700">Recent Activity</CardTitle>
</CardHeader>
<CardContent>
{recentInvoices.length === 0 ? (
<div className="text-muted-foreground py-12 text-center">
<div className="bg-muted mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full p-4">
<FileText className="text-muted-foreground h-8 w-8" />
</div>
<p className="text-foreground mb-2 text-lg font-medium">
No recent activity
</p>
<p className="text-muted-foreground text-sm">
Start by adding your first client or creating an invoice
</p>
</div>
) : (
<div className="space-y-4">
{recentInvoices.map((invoice) => (
<div
key={invoice.id}
className="bg-muted/50 flex items-center justify-between rounded-lg p-4"
>
<div className="flex items-center gap-3">
<div className="rounded-lg bg-emerald-100 p-2">
<FileText className="h-4 w-4 text-emerald-600" />
</div>
<div>
<p className="text-foreground font-medium">
Invoice #{invoice.invoiceNumber}
</p>
<p className="text-muted-foreground text-sm">
{invoice.client?.name ?? "Unknown Client"} $
{invoice.totalAmount.toFixed(2)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<StatusBadge status={invoice.status as StatusType} />
<Button variant="ghost" size="sm" asChild>
<Link href={`/dashboard/invoices/${invoice.id}`}>
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -2,8 +2,8 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { BusinessForm } from "~/components/business-form";
import { PageHeader } from "~/components/page-header";
import { BusinessForm } from "~/components/forms/business-form";
import { PageHeader } from "~/components/layout/page-header";
export default function EditBusinessPage() {
const params = useParams();

View File

@@ -3,7 +3,7 @@ import { api } from "~/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { PageHeader } from "~/components/page-header";
import { PageHeader } from "~/components/layout/page-header";
import Link from "next/link";
import {
Edit,

View File

@@ -3,7 +3,7 @@
import Link from "next/link";
import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "~/components/ui/button";
import { DataTable, DataTableColumnHeader } from "~/components/ui/data-table";
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
import { Building, Pencil, Trash2, ExternalLink } from "lucide-react";
import { useState } from "react";
import {

View File

@@ -1,7 +1,7 @@
"use client";
import { api } from "~/trpc/react";
import { DataTableSkeleton } from "~/components/ui/data-table";
import { DataTableSkeleton } from "~/components/data/data-table";
import { BusinessesDataTable } from "./businesses-data-table";
export function BusinessesTable() {

View File

@@ -1,6 +1,6 @@
import Link from "next/link";
import { BusinessForm } from "~/components/business-form";
import { PageHeader } from "~/components/page-header";
import { BusinessForm } from "~/components/forms/business-form";
import { PageHeader } from "~/components/layout/page-header";
import { HydrateClient } from "~/trpc/server";
export default function NewBusinessPage() {

View File

@@ -3,8 +3,8 @@ import { HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { Plus } from "lucide-react";
import { BusinessesTable } from "./_components/businesses-table";
import { PageHeader } from "~/components/page-header";
import { PageContent, PageSection } from "~/components/ui/page-layout";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent, PageSection } from "~/components/layout/page-layout";
export default async function BusinessesPage() {
return (

View File

@@ -1,7 +1,7 @@
import Link from "next/link";
import { HydrateClient } from "~/trpc/server";
import { ClientForm } from "~/components/client-form";
import { PageHeader } from "~/components/page-header";
import { ClientForm } from "~/components/forms/client-form";
import { PageHeader } from "~/components/layout/page-header";
interface EditClientPageProps {
params: Promise<{ id: string }>;

View File

@@ -3,7 +3,7 @@ import { api } from "~/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { PageHeader } from "~/components/page-header";
import { PageHeader } from "~/components/layout/page-header";
import Link from "next/link";
import {
Edit,

View File

@@ -3,7 +3,7 @@
import Link from "next/link";
import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "~/components/ui/button";
import { DataTable, DataTableColumnHeader } from "~/components/ui/data-table";
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
import { UserPlus, Pencil, Trash2 } from "lucide-react";
import { useState } from "react";
import {

View File

@@ -1,7 +1,7 @@
"use client";
import { api } from "~/trpc/react";
import { DataTableSkeleton } from "~/components/ui/data-table";
import { DataTableSkeleton } from "~/components/data/data-table";
import { ClientsDataTable } from "./clients-data-table";
export function ClientsTable() {

View File

@@ -1,7 +1,7 @@
import Link from "next/link";
import { HydrateClient } from "~/trpc/server";
import { ClientForm } from "~/components/client-form";
import { PageHeader } from "~/components/page-header";
import { ClientForm } from "~/components/forms/client-form";
import { PageHeader } from "~/components/layout/page-header";
export default async function NewClientPage() {
return (

View File

@@ -3,8 +3,8 @@ import { HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { Plus } from "lucide-react";
import { ClientsTable } from "./_components/clients-table";
import { PageHeader } from "~/components/page-header";
import { PageContent, PageSection } from "~/components/ui/page-layout";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent, PageSection } from "~/components/layout/page-layout";
export default async function ClientsPage() {
return (

View File

@@ -0,0 +1,64 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Button } from "~/components/ui/button";
import {
MoreHorizontal,
Edit,
Copy,
Send,
Trash2,
} from "lucide-react";
interface InvoiceActionsDropdownProps {
invoiceId: string;
}
export function InvoiceActionsDropdown({ invoiceId }: InvoiceActionsDropdownProps) {
const handleSendClick = () => {
const sendButton = document.querySelector(
"[data-testid='send-invoice-button']",
) as HTMLButtonElement;
sendButton?.click();
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="border-0 shadow-sm"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem>
<Edit className="mr-2 h-4 w-4" />
Edit Invoice
</DropdownMenuItem>
<DropdownMenuItem>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSendClick}>
<Send className="mr-2 h-4 w-4" />
Send to Client
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,186 @@
import { Card, CardContent, CardHeader } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import { Skeleton } from "~/components/ui/skeleton";
export function InvoiceDetailsSkeleton() {
return (
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 xl:col-span-2">
{/* Invoice Header Skeleton */}
<Card className="border-0 shadow-sm">
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Skeleton className="h-6 w-48 sm:h-8" />
<Skeleton className="h-6 w-16" />
</div>
<Skeleton className="mt-1 h-4 w-64" />
</div>
<div className="text-left sm:text-right">
<Skeleton className="h-4 w-20" />
<Skeleton className="mt-1 h-6 w-24 sm:h-8" />
</div>
</div>
</CardContent>
</Card>
{/* Client & Business Information Skeleton */}
<div className="grid gap-4 sm:gap-6 lg:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i} className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 sm:h-5 sm:w-5" />
<Skeleton className="h-6 w-16" />
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<Skeleton className="h-5 w-32 sm:h-6" />
<div className="space-y-2 sm:space-y-3">
{Array.from({ length: 3 }).map((_, j) => (
<div key={j} className="flex items-center gap-2 sm:gap-3">
<Skeleton className="h-6 w-6 rounded-lg sm:h-8 sm:w-8" />
<Skeleton className="h-3 w-28 sm:h-4" />
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
{/* Invoice Items Skeleton */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 sm:h-5 sm:w-5" />
<Skeleton className="h-5 w-28 sm:h-6" />
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full min-w-[500px]">
<thead>
<tr className="border-b">
{["Date", "Description", "Hours", "Rate", "Amount"].map(
(header) => (
<th key={header} className="p-2 text-left sm:p-4">
<Skeleton className="h-3 w-16 sm:h-4" />
</th>
),
)}
</tr>
</thead>
<tbody>
{Array.from({ length: 3 }).map((_, i) => (
<tr key={i} className="border-b last:border-0">
<td className="p-2 sm:p-4">
<Skeleton className="h-3 w-20 sm:h-4" />
</td>
<td className="p-2 sm:p-4">
<Skeleton className="h-3 w-48 sm:h-4" />
</td>
<td className="p-2 sm:p-4">
<Skeleton className="h-3 w-12 sm:h-4" />
</td>
<td className="p-2 sm:p-4">
<Skeleton className="h-3 w-16 sm:h-4" />
</td>
<td className="p-2 sm:p-4">
<Skeleton className="h-3 w-20 sm:h-4" />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Totals Section Skeleton */}
<div className="bg-muted/20 border-t p-3 sm:p-4">
<div className="flex justify-end">
<div className="w-full max-w-64 space-y-2">
<div className="flex justify-between">
<Skeleton className="h-3 w-16 sm:h-4" />
<Skeleton className="h-3 w-20 sm:h-4" />
</div>
<div className="flex justify-between">
<Skeleton className="h-3 w-20 sm:h-4" />
<Skeleton className="h-3 w-20 sm:h-4" />
</div>
<Separator />
<div className="flex justify-between">
<Skeleton className="h-4 w-12 sm:h-6" />
<Skeleton className="h-4 w-24 sm:h-6" />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Notes Skeleton */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<Skeleton className="h-5 w-16 sm:h-6" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<Skeleton className="h-3 w-full sm:h-4" />
<Skeleton className="h-3 w-3/4 sm:h-4" />
<Skeleton className="h-3 w-1/2 sm:h-4" />
</div>
</CardContent>
</Card>
</div>
{/* Sidebar Skeleton */}
<div className="space-y-4 sm:space-y-6">
{/* Actions Skeleton */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<Skeleton className="h-5 w-16 sm:h-6" />
</CardHeader>
<CardContent className="space-y-2 sm:space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full sm:h-10" />
))}
</CardContent>
</Card>
{/* Details Skeleton */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 sm:h-5 sm:w-5" />
<Skeleton className="h-5 w-16 sm:h-6" />
</div>
</CardHeader>
<CardContent className="space-y-2 sm:space-y-3">
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-1">
<Skeleton className="h-3 w-16 sm:h-4" />
<Skeleton className="h-3 w-20 sm:h-4" />
</div>
))}
</div>
</CardContent>
</Card>
{/* Danger Zone Skeleton */}
<Card className="border-red-200 shadow-sm dark:border-red-800">
<CardHeader className="pb-3">
<Skeleton className="h-5 w-24 sm:h-6" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-full sm:h-10" />
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "~/components/data/data-table";
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(date));
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
// Type for invoice item data
interface InvoiceItem {
id: string;
invoiceId: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
position: number;
createdAt: Date;
}
interface InvoiceItemsTableProps {
items: InvoiceItem[];
}
const columns: ColumnDef<InvoiceItem>[] = [
{
accessorKey: "date",
header: "Date",
cell: ({ row }) => formatDate(row.getValue("date")),
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<div className="font-medium">{row.getValue("description")}</div>
),
},
{
accessorKey: "hours",
header: "Hours",
cell: ({ row }) => (
<div className="text-right">{row.getValue("hours")}</div>
),
},
{
accessorKey: "rate",
header: "Rate",
cell: ({ row }) => (
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
),
},
{
accessorKey: "amount",
header: "Amount",
cell: ({ row }) => (
<div className="text-right font-medium text-emerald-600">
{formatCurrency(row.getValue("amount"))}
</div>
),
},
];
export function InvoiceItemsTable({ items }: InvoiceItemsTableProps) {
return (
<DataTable
columns={columns}
data={items}
showSearch={false}
showColumnVisibility={false}
showPagination={false}
/>
);
}

View File

@@ -3,84 +3,43 @@
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import { generateInvoicePDF } from "~/lib/pdf-export";
import { Download, Loader2 } from "lucide-react";
interface Invoice {
id: string;
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
status: string;
totalAmount: number;
taxRate: number;
notes?: string | null;
business?: {
name: string;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
website?: string | null;
taxId?: string | null;
} | null;
client: {
name: string;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
};
items: Array<{
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}>;
}
interface PDFDownloadButtonProps {
invoice: Invoice;
variant?: "button" | "menu" | "icon";
invoiceId: string;
variant?: "default" | "outline" | "ghost" | "icon";
className?: string;
}
export function PDFDownloadButton({
invoice,
variant = "button",
invoiceId,
variant = "outline",
className,
}: PDFDownloadButtonProps) {
const [isGenerating, setIsGenerating] = useState(false);
// Fetch invoice data when PDF generation is triggered
const { refetch: fetchInvoice } = api.invoices.getById.useQuery(
{ id: invoiceId },
{ enabled: false },
);
const handleDownloadPDF = async () => {
if (isGenerating) return;
setIsGenerating(true);
try {
// Transform the invoice data to match the PDF interface
const pdfData = {
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status,
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
notes: invoice.notes,
business: invoice.business,
client: invoice.client,
items: invoice.items,
};
// Fetch fresh invoice data
const { data: invoiceData } = await fetchInvoice();
await generateInvoicePDF(pdfData);
if (!invoiceData) {
throw new Error("Invoice not found");
}
await generateInvoicePDF(invoiceData);
toast.success("PDF downloaded successfully");
} catch (error) {
console.error("PDF generation error:", error);
@@ -92,23 +51,6 @@ export function PDFDownloadButton({
}
};
if (variant === "menu") {
return (
<button
onClick={handleDownloadPDF}
disabled={isGenerating}
className="hover:bg-accent flex w-full items-center gap-2 px-2 py-1.5 text-sm"
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isGenerating ? "Generating..." : "Download PDF"}
</button>
);
}
if (variant === "icon") {
return (
<Button
@@ -116,12 +58,12 @@ export function PDFDownloadButton({
disabled={isGenerating}
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
className={className}
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
) : (
<Download className="h-4 w-4" />
<Download className="h-3 w-3 sm:h-4 sm:w-4" />
)}
</Button>
);
@@ -131,15 +73,21 @@ export function PDFDownloadButton({
<Button
onClick={handleDownloadPDF}
disabled={isGenerating}
className="w-full justify-start"
variant="outline"
variant={variant}
size="default"
className={`w-full shadow-sm ${className}`}
>
{isGenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Generating PDF...</span>
</>
) : (
<Download className="mr-2 h-4 w-4" />
<>
<Download className="mr-2 h-4 w-4" />
<span>Download PDF</span>
</>
)}
{isGenerating ? "Generating..." : "Download PDF"}
</Button>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { Send, Loader2 } from "lucide-react";
interface SendInvoiceButtonProps {
invoiceId: string;
variant?: "default" | "outline" | "ghost" | "icon";
className?: string;
}
export function SendInvoiceButton({
invoiceId,
variant = "outline",
className,
}: SendInvoiceButtonProps) {
const [isSending, setIsSending] = useState(false);
// Fetch invoice data when sending is triggered
const { refetch: fetchInvoice } = api.invoices.getById.useQuery(
{ id: invoiceId },
{ enabled: false },
);
const handleSendInvoice = async () => {
if (isSending) return;
setIsSending(true);
try {
// Fetch fresh invoice data
const { data: invoice } = await fetchInvoice();
if (!invoice) {
throw new Error("Invoice not found");
}
// Generate PDF blob for potential attachment
const pdfBlob = await generateInvoicePDFBlob(invoice);
// Create a temporary download URL for the PDF
const pdfUrl = URL.createObjectURL(pdfBlob);
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
// Format date
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
};
// Calculate days until due
const today = new Date();
const dueDate = new Date(invoice.dueDate);
const daysUntilDue = Math.ceil(
(dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
);
// Create professional email template
const subject = `Invoice ${invoice.invoiceNumber} - ${formatCurrency(invoice.totalAmount)}`;
const body = `Dear ${invoice.client.name},
I hope this email finds you well. Please find attached invoice ${invoice.invoiceNumber} for the services provided.
Invoice Details:
• Invoice Number: ${invoice.invoiceNumber}
• Issue Date: ${formatDate(invoice.issueDate)}
• Due Date: ${formatDate(invoice.dueDate)}
• Amount Due: ${formatCurrency(invoice.totalAmount)}
${daysUntilDue > 0 ? `• Payment Due: In ${daysUntilDue} days` : daysUntilDue === 0 ? `• Payment Due: Today` : `• Status: ${Math.abs(daysUntilDue)} days overdue`}
${invoice.notes ? `\nAdditional Notes:\n${invoice.notes}\n` : ""}
Please review the attached invoice and remit payment by the due date. If you have any questions or concerns regarding this invoice, please don't hesitate to contact me.
Thank you for your business!
Best regards,
${invoice.business?.name ?? "Your Business Name"}
${invoice.business?.email ? `\n${invoice.business.email}` : ""}
${invoice.business?.phone ? `\n${invoice.business.phone}` : ""}`;
// Create mailto link
const mailtoLink = `mailto:${invoice.client.email ?? ""}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
// Create a temporary link element to trigger mailto
const link = document.createElement("a");
link.href = mailtoLink;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the PDF URL object
URL.revokeObjectURL(pdfUrl);
toast.success("Email client opened with invoice details");
} catch (error) {
console.error("Send invoice error:", error);
toast.error(
error instanceof Error
? error.message
: "Failed to prepare invoice email",
);
} finally {
setIsSending(false);
}
};
if (variant === "icon") {
return (
<Button
onClick={handleSendInvoice}
disabled={isSending}
variant="ghost"
size="sm"
className={className}
>
{isSending ? (
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
) : (
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
)}
</Button>
);
}
return (
<Button
onClick={handleSendInvoice}
disabled={isSending}
variant={variant}
size="default"
className={`w-full shadow-sm ${className}`}
data-testid="send-invoice-button"
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Preparing Email...</span>
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
<span>Send Invoice</span>
</>
)}
</Button>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { InvoiceView } from "~/components/invoice-view";
import { InvoiceForm } from "~/components/invoice-form";
import { InvoiceView } from "~/components/data/invoice-view";
import { InvoiceForm } from "~/components/forms/invoice-form";
interface UnifiedInvoicePageProps {
invoiceId: string;

View File

@@ -1,56 +1,53 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { useRouter, useParams } from "next/navigation";
import {
ArrowLeft,
Building,
DollarSign,
Edit3,
Eye,
FileText,
Hash,
Loader2,
Plus,
Save,
Send,
Trash2,
User,
} from "lucide-react";
import Link from "next/link";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { PageHeader } from "~/components/page-header";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { DatePicker } from "~/components/ui/date-picker";
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
import { toast } from "sonner";
import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input";
import {
ArrowLeft,
Save,
Plus,
Trash2,
FileText,
Building,
User,
Loader2,
Send,
DollarSign,
Hash,
Edit3,
Eye,
} from "lucide-react";
interface EditInvoicePageProps {}
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Separator } from "~/components/ui/separator";
import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react";
interface InvoiceItem {
id?: string;

View File

@@ -4,78 +4,34 @@ import Link from "next/link";
import { api, HydrateClient } from "~/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
import { Separator } from "~/components/ui/separator";
import { PageHeader } from "~/components/page-header";
import { PageHeader } from "~/components/layout/page-header";
import { PDFDownloadButton } from "./_components/pdf-download-button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { SendInvoiceButton } from "./_components/send-invoice-button";
import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
import { InvoiceActionsDropdown } from "./_components/invoice-actions-dropdown";
import { InvoiceItemsTable } from "./_components/invoice-items-table";
import {
ArrowLeft,
Edit,
Send,
Copy,
MoreHorizontal,
CheckCircle,
Clock,
Calendar,
FileText,
Building,
User,
DollarSign,
Hash,
MapPin,
Calendar,
Copy,
Edit,
FileText,
Mail,
MapPin,
Phone,
User,
AlertTriangle,
Trash2,
} from "lucide-react";
interface InvoicePageProps {
params: Promise<{ id: string }>;
}
function InvoiceStatusBadge({
status,
dueDate,
}: {
status: string;
dueDate: Date;
}) {
const getStatus = (): "draft" | "sent" | "paid" | "overdue" => {
if (status === "paid") return "paid";
if (status === "draft") return "draft";
if (status === "sent") {
const due = new Date(dueDate);
return due < new Date() ? "overdue" : "sent";
}
return "draft";
};
const actualStatus = getStatus();
const icons = {
draft: FileText,
sent: Clock,
paid: CheckCircle,
overdue: Clock,
};
const Icon = icons[actualStatus];
return (
<StatusBadge status={actualStatus} className="flex items-center gap-1">
<Icon className="h-3 w-3" />
{actualStatus.charAt(0).toUpperCase() + actualStatus.slice(1)}
</StatusBadge>
);
}
async function InvoiceDetails({ invoiceId }: { invoiceId: string }) {
async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
const invoice = await api.invoices.getById({ id: invoiceId });
if (!invoice) {
@@ -97,379 +53,337 @@ async function InvoiceDetails({ invoiceId }: { invoiceId: string }) {
}).format(amount);
};
const subtotal =
invoice.items?.reduce((sum, item) => sum + item.hours * item.rate, 0) || 0;
const taxAmount = (subtotal * (invoice.taxRate || 0)) / 100;
const total = subtotal + taxAmount;
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = (subtotal * invoice.taxRate) / 100;
const isOverdue =
new Date(invoice.dueDate) < new Date() && invoice.status !== "paid";
const getStatusType = (): StatusType => {
if (invoice.status === "paid") return "paid";
if (invoice.status === "draft") return "draft";
if (invoice.status === "sent") {
return isOverdue ? "overdue" : "sent";
}
return "draft";
};
return (
<div className="space-y-6">
{/* Invoice Header */}
<Card className="border-0 shadow-lg">
<CardContent className="p-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
{/* Invoice Info */}
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="rounded-lg bg-emerald-100 p-3 dark:bg-emerald-900/30">
<Hash className="h-6 w-6 text-emerald-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-emerald-700 dark:text-emerald-400">
{invoice.invoiceNumber}
</h1>
<p className="text-muted-foreground text-sm">Invoice</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="flex items-center gap-2">
<Calendar className="text-muted-foreground h-4 w-4" />
<div>
<p className="text-muted-foreground text-xs font-medium">
Issued
</p>
<p className="text-sm font-semibold">
{formatDate(invoice.issueDate)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="text-muted-foreground h-4 w-4" />
<div>
<p className="text-muted-foreground text-xs font-medium">
Due
</p>
<p className="text-sm font-semibold">
{formatDate(invoice.dueDate)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<DollarSign className="text-muted-foreground h-4 w-4" />
<div>
<p className="text-muted-foreground text-xs font-medium">
Amount
</p>
<p className="text-sm font-semibold text-emerald-600">
{formatCurrency(total)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<FileText className="text-muted-foreground h-4 w-4" />
<div>
<p className="text-muted-foreground text-xs font-medium">
Status
</p>
<InvoiceStatusBadge
status={invoice.status}
dueDate={invoice.dueDate}
/>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2 sm:flex-row lg:flex-col">
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button className="w-full">
<Edit className="mr-2 h-4 w-4" />
Edit Invoice
</Button>
</Link>
<PDFDownloadButton invoice={invoice} variant="button" />
</div>
</div>
</CardContent>
</Card>
{/* Business & Client Info */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* From Business */}
<Card className="border-0 shadow-md">
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-lg">
<Building className="h-4 w-4 text-emerald-600" />
From
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{invoice.business ? (
<>
<div>
<p className="font-semibold">{invoice.business.name}</p>
</div>
<div className="space-y-1">
{invoice.business.email && (
<div className="flex items-center gap-2 text-sm">
<Mail className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground">
{invoice.business.email}
</span>
</div>
)}
{invoice.business.phone && (
<div className="flex items-center gap-2 text-sm">
<Phone className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground">
{invoice.business.phone}
</span>
</div>
)}
{invoice.business.addressLine1 && (
<div className="flex items-start gap-2 text-sm">
<MapPin className="text-muted-foreground mt-0.5 h-3 w-3" />
<div className="text-muted-foreground">
<p>{invoice.business.addressLine1}</p>
{invoice.business.addressLine2 && (
<p>{invoice.business.addressLine2}</p>
)}
<p>
{[
invoice.business.city,
invoice.business.state,
invoice.business.postalCode,
]
.filter(Boolean)
.join(", ")}
</p>
{invoice.business.country && (
<p>{invoice.business.country}</p>
)}
</div>
</div>
)}
</div>
</>
) : (
<p className="text-muted-foreground text-sm italic">
No business information
</p>
)}
</CardContent>
</Card>
{/* To Client */}
<Card className="border-0 shadow-md">
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-lg">
<User className="h-4 w-4 text-emerald-600" />
Bill To
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="font-semibold">{invoice.client.name}</p>
</div>
<div className="space-y-1">
{invoice.client.email && (
<div className="flex items-center gap-2 text-sm">
<Mail className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground">
{invoice.client.email}
</span>
</div>
)}
{invoice.client.phone && (
<div className="flex items-center gap-2 text-sm">
<Phone className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground">
{invoice.client.phone}
</span>
</div>
)}
{invoice.client.addressLine1 && (
<div className="flex items-start gap-2 text-sm">
<MapPin className="text-muted-foreground mt-0.5 h-3 w-3" />
<div className="text-muted-foreground">
<p>{invoice.client.addressLine1}</p>
{invoice.client.addressLine2 && (
<p>{invoice.client.addressLine2}</p>
)}
<p>
{[
invoice.client.city,
invoice.client.state,
invoice.client.postalCode,
]
.filter(Boolean)
.join(", ")}
</p>
{invoice.client.country && <p>{invoice.client.country}</p>}
</div>
</div>
)}
{/* Overdue Alert */}
{isOverdue && (
<Card className="border-red-200 bg-red-50">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-red-700">
<AlertTriangle className="h-5 w-5" />
<span className="font-medium">
This invoice is{" "}
{Math.ceil(
(new Date().getTime() - new Date(invoice.dueDate).getTime()) /
(1000 * 60 * 60 * 24),
)}{" "}
days overdue
</span>
</div>
</CardContent>
</Card>
</div>
)}
{/* Line Items */}
<Card className="border-0 shadow-lg">
<CardHeader className="border-b">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-emerald-600" />
Line Items ({invoice.items?.length || 0})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
{invoice.items && invoice.items.length > 0 ? (
<div className="space-y-0">
{/* Header - Hidden on mobile */}
<div className="border-muted/30 bg-muted/20 hidden grid-cols-12 gap-4 border-b px-6 py-3 text-sm font-medium md:grid">
<div className="col-span-2">Date</div>
<div className="col-span-5">Description</div>
<div className="col-span-2 text-right">Hours</div>
<div className="col-span-2 text-right">Rate</div>
<div className="col-span-1 text-right">Amount</div>
</div>
{/* Items */}
{invoice.items.map((item, index) => (
<div
key={index}
className="border-muted/30 grid grid-cols-1 gap-2 border-b px-6 py-4 last:border-b-0 md:grid-cols-12 md:items-center md:gap-4"
>
{/* Mobile Layout */}
<div className="md:hidden">
<div className="mb-2 flex items-start justify-between">
<p className="font-medium">{item.description}</p>
<span className="font-mono text-sm font-semibold text-emerald-600">
{formatCurrency(item.hours * item.rate)}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<div>
<span className="text-muted-foreground text-xs">
Date
</span>
<p>{formatDate(item.date)}</p>
</div>
<div>
<span className="text-muted-foreground text-xs">
Hours
</span>
<p className="font-mono">{item.hours}</p>
</div>
<div>
<span className="text-muted-foreground text-xs">
Rate
</span>
<p className="font-mono">
{formatCurrency(item.rate)}
</p>
</div>
</div>
</div>
{/* Desktop Layout */}
<div className="text-muted-foreground col-span-2 hidden text-sm md:block">
{formatDate(item.date)}
</div>
<div className="col-span-5 hidden font-medium md:block">
{item.description}
</div>
<div className="col-span-2 hidden text-right font-mono text-sm md:block">
{item.hours}
</div>
<div className="col-span-2 hidden text-right font-mono text-sm md:block">
{formatCurrency(item.rate)}
</div>
<div className="col-span-1 hidden text-right font-mono font-semibold text-emerald-600 md:block">
{formatCurrency(item.hours * item.rate)}
</div>
<div className="grid gap-6 lg:grid-cols-4 xl:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-3 xl:col-span-2">
{/* Invoice Header */}
<Card className="shadow-lg">
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-3">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold sm:text-2xl">
Invoice #{invoice.invoiceNumber}
</h1>
<StatusBadge status={getStatusType()} />
</div>
))}
<p className="text-muted-foreground text-sm sm:text-base">
Issued {formatDate(invoice.issueDate)} Due{" "}
{formatDate(invoice.dueDate)}
</p>
</div>
<div className="text-left sm:text-right">
<p className="text-muted-foreground text-sm sm:text-base">
Total Amount
</p>
<p className="text-2xl font-bold text-emerald-600 sm:text-3xl">
{formatCurrency(invoice.totalAmount)}
</p>
</div>
</div>
) : (
<div className="text-muted-foreground py-12 text-center">
<FileText className="mx-auto mb-2 h-8 w-8" />
<p>No line items found</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Totals & Notes */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Notes */}
{invoice.notes && (
<Card className="border-0 shadow-md lg:col-span-2">
<CardHeader className="pb-4">
<CardTitle className="text-lg">Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground leading-relaxed">
{invoice.notes}
</p>
</CardContent>
</Card>
)}
{/* Totals */}
<Card
className={`border-0 shadow-md ${!invoice.notes ? "lg:col-start-3" : ""}`}
>
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-lg">
<DollarSign className="h-4 w-4 text-emerald-600" />
Total
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal:</span>
<span className="font-mono">{formatCurrency(subtotal)}</span>
</div>
{invoice.taxRate > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Tax ({invoice.taxRate}%):
</span>
<span className="font-mono">{formatCurrency(taxAmount)}</span>
{/* Client & Business Information */}
<div className="grid gap-4 sm:gap-6 md:grid-cols-2">
{/* Client Information */}
<Card className="shadow-lg">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-emerald-600">
<User className="h-4 w-4 sm:h-5 sm:w-5" />
Bill To
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div>
<h3 className="text-foreground text-lg font-semibold sm:text-xl">
{invoice.client.name}
</h3>
</div>
)}
<Separator />
<div className="flex justify-between text-lg font-bold">
<span>Total:</span>
<span className="font-mono text-emerald-600">
{formatCurrency(total)}
</span>
</div>
</div>
{/* Status Actions */}
<div className="pt-2">
{invoice.status === "draft" && (
<Button className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700">
<Send className="mr-2 h-4 w-4" />
Send Invoice
</Button>
)}
<div className="space-y-2 sm:space-y-3">
{invoice.client.email && (
<div className="flex items-center gap-2 sm:gap-3">
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
<Mail className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
</div>
<span className="text-sm break-all sm:text-base">
{invoice.client.email}
</span>
</div>
)}
{invoice.status === "sent" && (
<Button className="w-full bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700">
<CheckCircle className="mr-2 h-4 w-4" />
Mark as Paid
</Button>
)}
{invoice.client.phone && (
<div className="flex items-center gap-2 sm:gap-3">
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
<Phone className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
</div>
<span className="text-sm sm:text-base">
{invoice.client.phone}
</span>
</div>
)}
{(invoice.status === "paid" || invoice.status === "overdue") && (
<div className="text-center">
<InvoiceStatusBadge
status={invoice.status}
dueDate={invoice.dueDate}
/>
{(invoice.client.addressLine1 ?? invoice.client.city) && (
<div className="flex items-start gap-2 sm:gap-3">
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
<MapPin className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
</div>
<div className="text-sm sm:text-base">
{invoice.client.addressLine1 && (
<div>{invoice.client.addressLine1}</div>
)}
{invoice.client.addressLine2 && (
<div>{invoice.client.addressLine2}</div>
)}
{(invoice.client.city ??
invoice.client.state ??
invoice.client.postalCode) && (
<div>
{[
invoice.client.city,
invoice.client.state,
invoice.client.postalCode,
]
.filter(Boolean)
.join(", ")}
</div>
)}
{invoice.client.country && (
<div>{invoice.client.country}</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{/* Business Information */}
{invoice.business && (
<Card className="shadow-lg">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-emerald-600">
<Building className="h-4 w-4 sm:h-5 sm:w-5" />
From
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div>
<h3 className="text-foreground text-lg font-semibold sm:text-xl">
{invoice.business.name}
</h3>
</div>
<div className="space-y-2 sm:space-y-3">
{invoice.business.email && (
<div className="flex items-center gap-2 sm:gap-3">
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
<Mail className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
</div>
<span className="text-sm break-all sm:text-base">
{invoice.business.email}
</span>
</div>
)}
{invoice.business.phone && (
<div className="flex items-center gap-2 sm:gap-3">
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
<Phone className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
</div>
<span className="text-sm sm:text-base">
{invoice.business.phone}
</span>
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
{/* Invoice Items */}
<Card className="shadow-lg">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Invoice Items
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<InvoiceItemsTable items={invoice.items} />
{/* Totals */}
<div className="mt-6 border-t pt-4">
<div className="flex justify-end">
<div className="w-full space-y-2 sm:max-w-64">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal:</span>
<span className="font-medium">
{formatCurrency(subtotal)}
</span>
</div>
{invoice.taxRate > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Tax ({invoice.taxRate}%):
</span>
<span className="font-medium">
{formatCurrency(taxAmount)}
</span>
</div>
)}
<Separator />
<div className="flex justify-between text-base font-bold sm:text-lg">
<span>Total:</span>
<span className="text-emerald-600">
{formatCurrency(invoice.totalAmount)}
</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Notes */}
{invoice.notes && (
<Card className="shadow-lg">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Notes
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
{invoice.notes}
</p>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-4 sm:space-y-6 lg:col-span-1">
{/* Actions */}
<Card className="shadow-lg">
<CardHeader className="pb-3">
<CardTitle className="text-base sm:text-lg">Actions</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Button
asChild
variant="outline"
className="w-full border-0 shadow-sm"
size="default"
>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
<span>Edit Invoice</span>
</Link>
</Button>
<PDFDownloadButton invoiceId={invoice.id} />
<SendInvoiceButton invoiceId={invoice.id} />
<Button
variant="outline"
className="w-full border-0 shadow-sm"
size="default"
>
<Copy className="mr-2 h-4 w-4" />
<span>Duplicate</span>
</Button>
<Button variant="destructive" size="default" className="w-full">
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete Invoice</span>
</Button>
</CardContent>
</Card>
{/* Invoice Details */}
<Card className="shadow-lg">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
<Calendar className="h-4 w-4 sm:h-5 sm:w-5" />
Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="text-muted-foreground text-sm">Invoice #</p>
<p className="font-medium break-all">
{invoice.invoiceNumber}
</p>
</div>
<div>
<p className="text-muted-foreground text-sm">Status</p>
<div className="mt-1">
<StatusBadge status={getStatusType()} />
</div>
</div>
<div>
<p className="text-muted-foreground text-sm">Issue Date</p>
<p className="font-medium">{formatDate(invoice.issueDate)}</p>
</div>
<div>
<p className="text-muted-foreground text-sm">Due Date</p>
<p className="font-medium">{formatDate(invoice.dueDate)}</p>
</div>
<div>
<p className="text-muted-foreground text-sm">Tax Rate</p>
<p className="font-medium">{invoice.taxRate}%</p>
</div>
<div>
<p className="text-muted-foreground text-sm">Total</p>
<p className="font-medium text-emerald-600">
{formatCurrency(invoice.totalAmount)}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
@@ -479,56 +393,35 @@ export default async function InvoicePage({ params }: InvoicePageProps) {
const { id } = await params;
return (
<div className="space-y-6">
<>
<PageHeader
title="Invoice Details"
description="View and manage invoice information"
variant="gradient"
>
<div className="flex items-center gap-2">
<Link href="/dashboard/invoices">
<Button variant="outline" size="sm">
<div className="flex items-center gap-2 sm:gap-3">
<Button
asChild
variant="outline"
className="border-0 shadow-sm"
size="default"
>
<Link href="/dashboard/invoices">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</Link>
<span className="hidden sm:inline">Back to Invoices</span>
<span className="sm:hidden">Back</span>
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/dashboard/invoices/${id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Invoice
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Send className="mr-2 h-4 w-4" />
Download PDF
</DropdownMenuItem>
<DropdownMenuItem>
<Send className="mr-2 h-4 w-4" />
Send Invoice
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<InvoiceActionsDropdown invoiceId={id} />
</div>
</PageHeader>
<HydrateClient>
<Suspense fallback={<div>Loading invoice details...</div>}>
<InvoiceDetails invoiceId={id} />
<Suspense fallback={<InvoiceDetailsSkeleton />}>
<InvoiceContent invoiceId={id} />
</Suspense>
</HydrateClient>
</div>
</>
);
}

View File

@@ -3,10 +3,10 @@
import Link from "next/link";
import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "~/components/ui/button";
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
import { DataTable, DataTableColumnHeader } from "~/components/ui/data-table";
import { EmptyState } from "~/components/ui/page-layout";
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
import { EmptyState } from "~/components/layout/page-layout";
import { Plus, FileText, Eye, Edit } from "lucide-react";
// Type for invoice data
@@ -182,38 +182,7 @@ const columns: ColumnDef<Invoice>[] = [
</Button>
</Link>
{invoice.items && invoice.client && (
<PDFDownloadButton
invoice={{
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status,
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
notes: invoice.notes,
business: invoice.business
? {
name: invoice.business.name,
email: invoice.business.email,
phone: invoice.business.phone,
}
: null,
client: {
name: invoice.client.name,
email: invoice.client.email,
phone: invoice.client.phone,
},
items: invoice.items.map((item) => ({
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
})),
}}
variant="icon"
/>
<PDFDownloadButton invoiceId={invoice.id} variant="icon" />
)}
</div>
);

View File

@@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { PageHeader } from "~/components/page-header";
import { PageHeader } from "~/components/layout/page-header";
import {
ArrowLeft,
Upload,

View File

@@ -10,7 +10,7 @@ import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { PageHeader } from "~/components/page-header";
import { PageHeader } from "~/components/layout/page-header";
import {
Select,
SelectContent,
@@ -32,7 +32,7 @@ import {
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { DatePicker } from "~/components/ui/date-picker";
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { toast } from "sonner";
import {
ArrowLeft,

View File

@@ -2,10 +2,10 @@ import Link from "next/link";
import { Suspense } from "react";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { PageHeader } from "~/components/page-header";
import { PageHeader } from "~/components/layout/page-header";
import { Plus, Upload } from "lucide-react";
import { InvoicesDataTable } from "./_components/invoices-data-table";
import { DataTableSkeleton } from "~/components/ui/data-table";
import { DataTableSkeleton } from "~/components/data/data-table";
// Invoices Table Component
async function InvoicesTable() {

View File

@@ -1,6 +1,6 @@
import { Navbar } from "~/components/Navbar";
import { Sidebar } from "~/components/Sidebar";
import { DashboardBreadcrumbs } from "~/components/dashboard-breadcrumbs";
import { Navbar } from "~/components/layout/navbar";
import { Sidebar } from "~/components/layout/sidebar";
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
export default function DashboardLayout({
children,

View File

@@ -1,36 +1,258 @@
import { Suspense } from "react";
import { HydrateClient, api } from "~/trpc/server";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
import { DataTableSkeleton } from "~/components/data/data-table";
import { auth } from "~/server/auth";
import { HydrateClient } from "~/trpc/server";
import Link from "next/link";
import {
DashboardStats,
DashboardCards,
DashboardActivity,
} from "./_components/dashboard-components";
import { DashboardPageHeader } from "~/components/page-header";
import { PageContent, PageSection } from "~/components/ui/page-layout";
Users,
FileText,
TrendingUp,
DollarSign,
Plus,
Eye,
Calendar,
ArrowUpRight,
} from "lucide-react";
// Stats Cards Component
async function DashboardStats() {
const [clients, invoices] = await Promise.all([
api.clients.getAll(),
api.invoices.getAll(),
]);
const totalClients = clients.length;
const totalInvoices = invoices.length;
const totalRevenue = invoices.reduce(
(sum, invoice) => sum + invoice.totalAmount,
0,
);
const pendingInvoices = invoices.filter(
(invoice) => invoice.status === "sent" || invoice.status === "draft",
).length;
const stats = [
{
title: "Total Clients",
value: totalClients.toString(),
icon: Users,
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-100 dark:bg-blue-900/20",
},
{
title: "Total Invoices",
value: totalInvoices.toString(),
icon: FileText,
color: "text-emerald-600 dark:text-emerald-400",
bgColor: "bg-emerald-100 dark:bg-emerald-900/20",
},
{
title: "Total Revenue",
value: `$${totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
icon: DollarSign,
color: "text-teal-600 dark:text-teal-400",
bgColor: "bg-teal-100 dark:bg-teal-900/20",
},
{
title: "Pending Invoices",
value: pendingInvoices.toString(),
icon: Calendar,
color: "text-amber-600 dark:text-amber-400",
bgColor: "bg-amber-100 dark:bg-amber-900/20",
},
];
return (
<Card className="mb-4 border-0 shadow-sm">
<CardContent className="p-4 py-0">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div key={stat.title} className="flex items-center space-x-3">
<div className={`rounded-lg p-2 ${stat.bgColor}`}>
<Icon className={`h-4 w-4 ${stat.color}`} />
</div>
<div className="min-w-0">
<p className="text-muted-foreground text-xs font-medium">
{stat.title}
</p>
<p className={`text-lg font-bold ${stat.color}`}>
{stat.value}
</p>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
// Quick Actions Component
function QuickActions() {
return (
<Card className="mb-6 border-0 shadow-sm">
<CardContent className="p-4 py-0">
<div className="flex flex-col gap-3 sm:flex-row sm:gap-4">
<Button
asChild
className="flex-1 bg-gradient-to-r from-emerald-600 to-teal-600 text-white shadow-sm hover:from-emerald-700 hover:to-teal-700"
>
<Link href="/dashboard/invoices/new">
<FileText className="mr-2 h-4 w-4" />
Create Invoice
</Link>
</Button>
<Button
asChild
variant="outline"
className="flex-1 border-0 shadow-sm"
>
<Link href="/dashboard/clients/new">
<Users className="mr-2 h-4 w-4" />
Add Client
</Link>
</Button>
<Button
asChild
variant="outline"
className="flex-1 border-0 shadow-sm"
>
<Link href="/dashboard/businesses/new">
<TrendingUp className="mr-2 h-4 w-4" />
Add Business
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
// Recent Activity Component
async function RecentActivity() {
const invoices = await api.invoices.getAll();
const recentInvoices = invoices
.sort(
(a, b) =>
new Date(b.issueDate).getTime() - new Date(a.issueDate).getTime(),
)
.slice(0, 5);
if (recentInvoices.length === 0) {
return (
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Recent Activity
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-8 text-center">
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<p className="text-muted-foreground">
No invoices yet. Create your first invoice to get started!
</p>
<Button
asChild
className="mt-4 bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-700 hover:to-teal-700"
>
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create Invoice
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="border-0 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Calendar className="text-muted-foreground h-5 w-5" />
Recent Activity
</CardTitle>
<Button variant="outline" size="sm" asChild>
<Link href="/dashboard/invoices">
View All
<ArrowUpRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentInvoices.map((invoice) => (
<div
key={invoice.id}
className="hover:bg-muted/50 flex items-center justify-between rounded-lg border p-4 transition-colors"
>
<div className="flex items-center space-x-4">
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/20">
<FileText className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="font-medium">
Invoice #{invoice.invoiceNumber}
</p>
<p className="text-muted-foreground text-sm">
{invoice.client?.name} ${invoice.totalAmount.toFixed(2)}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<StatusBadge status={invoice.status as StatusType} />
<Button variant="ghost" size="sm" asChild>
<Link href={`/dashboard/invoices/${invoice.id}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
export default async function DashboardPage() {
const session = await auth();
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
return (
<PageContent>
<DashboardPageHeader
title={`Welcome back, ${session?.user?.name?.split(" ")[0] ?? "User"}!`}
description="Here's what's happening with your invoicing business"
<>
<PageHeader
title={`Welcome back, ${firstName}!`}
description="Here's an overview of your invoicing business"
variant="gradient"
/>
<HydrateClient>
<PageSection>
<DashboardStats />
</PageSection>
<div className="space-y-6">
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={4} rows={1} />}>
<DashboardStats />
</Suspense>
</HydrateClient>
<PageSection>
<DashboardCards />
</PageSection>
<QuickActions />
<PageSection>
<DashboardActivity />
</PageSection>
</HydrateClient>
</PageContent>
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={1} rows={3} />}>
<RecentActivity />
</Suspense>
</HydrateClient>
</div>
</>
);
}

View File

@@ -0,0 +1,532 @@
"use client";
import { useState } from "react";
import * as React from "react";
import { useSession } from "next-auth/react";
import {
Download,
Upload,
User,
Database,
AlertTriangle,
Shield,
Settings,
FileText,
Users,
Building,
} from "lucide-react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { Badge } from "~/components/ui/badge";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
export function SettingsContent() {
const { data: session } = useSession();
const [name, setName] = useState("");
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [importData, setImportData] = useState("");
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
// Queries
const { data: profile, refetch: refetchProfile } =
api.settings.getProfile.useQuery();
const { data: dataStats } = api.settings.getDataStats.useQuery();
// Mutations
const updateProfileMutation = api.settings.updateProfile.useMutation({
onSuccess: () => {
toast.success("Profile updated successfully");
void refetchProfile();
},
onError: (error: { message: string }) => {
toast.error(`Failed to update profile: ${error.message}`);
},
});
const exportDataQuery = api.settings.exportData.useQuery(undefined, {
enabled: false,
});
// Handle export data success/error
React.useEffect(() => {
if (exportDataQuery.data && !exportDataQuery.isFetching) {
const blob = new Blob([JSON.stringify(exportDataQuery.data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `beenvoice-backup-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Data backup downloaded successfully");
}
if (exportDataQuery.error) {
toast.error(`Export failed: ${exportDataQuery.error.message}`);
}
}, [exportDataQuery.data, exportDataQuery.isFetching, exportDataQuery.error]);
const importDataMutation = api.settings.importData.useMutation({
onSuccess: (result) => {
toast.success(
`Data imported successfully! Added ${result.imported.clients} clients, ${result.imported.businesses} businesses, and ${result.imported.invoices} invoices.`,
);
setImportData("");
setIsImportDialogOpen(false);
void refetchProfile();
},
onError: (error: { message: string }) => {
toast.error(`Import failed: ${error.message}`);
},
});
const deleteDataMutation = api.settings.deleteAllData.useMutation({
onSuccess: () => {
toast.success("All data has been permanently deleted");
setDeleteConfirmText("");
},
onError: (error: { message: string }) => {
toast.error(`Delete failed: ${error.message}`);
},
});
const handleUpdateProfile = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
toast.error("Please enter your name");
return;
}
updateProfileMutation.mutate({ name: name.trim() });
};
const handleExportData = () => {
void exportDataQuery.refetch();
};
// Type guard for backup data
const isValidBackupData = (
data: unknown,
): data is {
exportDate: string;
version: string;
user: { name?: string; email: string };
clients: Array<{
name: string;
email?: string;
phone?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
}>;
businesses: Array<{
name: string;
email?: string;
phone?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
website?: string;
taxId?: string;
logoUrl?: string;
isDefault?: boolean;
}>;
invoices: Array<{
invoiceNumber: string;
businessName?: string;
clientName: string;
issueDate: Date;
dueDate: Date;
status?: string;
totalAmount?: number;
taxRate?: number;
notes?: string;
items: Array<{
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
position?: number;
}>;
}>;
} => {
return !!(
data &&
typeof data === "object" &&
data !== null &&
"exportDate" in data &&
"version" in data &&
"user" in data &&
"clients" in data &&
"businesses" in data &&
"invoices" in data
);
};
const handleImportData = () => {
try {
const parsedData: unknown = JSON.parse(importData);
if (isValidBackupData(parsedData)) {
importDataMutation.mutate(parsedData);
} else {
toast.error("Invalid backup file format");
}
} catch {
toast.error("Invalid JSON format. Please check your backup file.");
}
};
const handleDeleteAllData = () => {
if (deleteConfirmText !== "delete all my data") {
toast.error("Please type 'delete all my data' to confirm");
return;
}
deleteDataMutation.mutate({ confirmText: deleteConfirmText });
};
// Set initial name value when profile loads
React.useEffect(() => {
if (profile?.name && !name) {
setName(profile.name);
}
}, [profile?.name, name]);
const dataStatItems = [
{
label: "Clients",
value: dataStats?.clients ?? 0,
icon: Users,
color: "text-blue-600",
bgColor: "bg-blue-100",
},
{
label: "Businesses",
value: dataStats?.businesses ?? 0,
icon: Building,
color: "text-purple-600",
bgColor: "bg-purple-100",
},
{
label: "Invoices",
value: dataStats?.invoices ?? 0,
icon: FileText,
color: "text-emerald-600",
bgColor: "bg-emerald-100",
},
];
return (
<div className="space-y-8">
{/* Profile & Account Overview */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Profile Section */}
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-emerald-100 p-2">
<User className="h-5 w-5 text-emerald-600" />
</div>
Profile Information
</CardTitle>
<CardDescription>
Update your personal account details
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUpdateProfile} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your full name"
className="border-0 shadow-sm"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
value={session?.user?.email ?? ""}
disabled
className="bg-muted border-0 shadow-sm"
/>
<p className="text-muted-foreground text-sm">
Email address cannot be changed
</p>
</div>
<Button
type="submit"
disabled={updateProfileMutation.isPending}
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700"
>
{updateProfileMutation.isPending
? "Updating..."
: "Save Changes"}
</Button>
</form>
</CardContent>
</Card>
{/* Data Overview */}
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-blue-100 p-2">
<Database className="h-5 w-5 text-blue-600" />
</div>
Account Data
</CardTitle>
<CardDescription>
Overview of your stored information
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{dataStatItems.map((item) => {
const Icon = item.icon;
return (
<div
key={item.label}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<div className={`rounded-lg p-2 ${item.bgColor}`}>
<Icon className={`h-4 w-4 ${item.color}`} />
</div>
<span className="font-medium">{item.label}</span>
</div>
<Badge
variant="secondary"
className="text-lg font-semibold"
>
{item.value}
</Badge>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
{/* Data Management */}
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-indigo-100 p-2">
<Shield className="h-5 w-5 text-indigo-600" />
</div>
Data Management
</CardTitle>
<CardDescription>
Backup, restore, or manage your account data
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="flex gap-4">
<Button
onClick={handleExportData}
disabled={exportDataQuery.isFetching}
variant="outline"
className="flex-1"
>
<Download className="mr-2 h-4 w-4" />
{exportDataQuery.isFetching ? "Exporting..." : "Export Backup"}
</Button>
<Dialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
>
<DialogTrigger asChild>
<Button variant="outline" className="flex-1">
<Upload className="mr-2 h-4 w-4" />
Import Backup
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Import Backup Data</DialogTitle>
<DialogDescription>
Paste the contents of your backup JSON file below. This
will add the data to your existing account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Textarea
placeholder="Paste your backup JSON data here..."
value={importData}
onChange={(e) => setImportData(e.target.value)}
rows={12}
className="font-mono text-sm"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsImportDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleImportData}
disabled={
!importData.trim() || importDataMutation.isPending
}
className="bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700"
>
{importDataMutation.isPending
? "Importing..."
: "Import Data"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{/* Backup Information */}
<div className="mt-6 rounded-lg border p-4">
<h4 className="font-medium">Backup Information</h4>
<ul className="text-muted-foreground mt-2 space-y-1 text-sm">
<li> Regular backups protect your important business data</li>
<li> Backup files contain all data in secure JSON format</li>
<li> Import adds to existing data without replacing anything</li>
<li> Store backup files in a secure, accessible location</li>
</ul>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-red-100 p-2">
<AlertTriangle className="h-5 w-5 text-red-600" />
</div>
Data Management
</CardTitle>
<CardDescription>
Manage your account data with caution
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="rounded-lg border p-4">
<h4 className="font-medium text-red-600">
Delete All Account Data
</h4>
<p className="text-muted-foreground mt-2 text-sm">
This will permanently delete all your clients, businesses,
invoices, and related data. This action cannot be undone and
your data cannot be recovered.
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-100">
<AlertTriangle className="mr-2 h-4 w-4" />
Delete All Data
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription className="space-y-4">
<div>
This action cannot be undone. This will permanently delete
all your:
</div>
<ul className="list-inside list-disc space-y-1 rounded-lg border p-3 text-sm">
<li>Client information and contact details</li>
<li>Business profiles and settings</li>
<li>Invoices and invoice line items</li>
<li>All related data and records</li>
</ul>
<div className="space-y-2">
<div className="font-medium">
Type{" "}
<span className="bg-muted rounded px-2 py-1 font-mono text-sm">
delete all my data
</span>{" "}
to confirm:
</div>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="Type delete all my data"
className="font-mono"
/>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteAllData}
disabled={
deleteConfirmText !== "delete all my data" ||
deleteDataMutation.isPending
}
className="bg-red-600 hover:bg-red-700"
>
{deleteDataMutation.isPending
? "Deleting..."
: "Delete Forever"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,502 +1,23 @@
"use client";
import { useState } from "react";
import * as React from "react";
import { useSession } from "next-auth/react";
import {
Download,
Upload,
User,
Database,
AlertTriangle,
Shield,
} from "lucide-react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Textarea } from "~/components/ui/textarea";
import { PageHeader } from "~/components/page-header";
export default function SettingsPage() {
const { data: session } = useSession();
const [name, setName] = useState("");
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [importData, setImportData] = useState("");
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
// Queries
const { data: profile, refetch: refetchProfile } =
api.settings.getProfile.useQuery();
const { data: dataStats } = api.settings.getDataStats.useQuery();
// Mutations
const updateProfileMutation = api.settings.updateProfile.useMutation({
onSuccess: () => {
toast.success("Your profile has been successfully updated.");
void refetchProfile();
},
onError: (error: { message: string }) => {
toast.error(`Error updating profile: ${error.message}`);
},
});
const exportDataQuery = api.settings.exportData.useQuery(undefined, {
enabled: false,
});
// Handle export data success/error
React.useEffect(() => {
if (exportDataQuery.data && !exportDataQuery.isFetching) {
// Create and download the backup file
const blob = new Blob([JSON.stringify(exportDataQuery.data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `beenvoice-backup-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Your data backup has been downloaded.");
}
if (exportDataQuery.error) {
toast.error(`Error exporting data: ${exportDataQuery.error.message}`);
}
}, [exportDataQuery.data, exportDataQuery.isFetching, exportDataQuery.error]);
const importDataMutation = api.settings.importData.useMutation({
onSuccess: (result) => {
toast.success(
`Data imported successfully! Imported ${result.imported.clients} clients, ${result.imported.businesses} businesses, and ${result.imported.invoices} invoices.`,
);
setImportData("");
setIsImportDialogOpen(false);
void refetchProfile();
},
onError: (error: { message: string }) => {
toast.error(`Error importing data: ${error.message}`);
},
});
const deleteDataMutation = api.settings.deleteAllData.useMutation({
onSuccess: () => {
toast.success("Your account data has been permanently deleted.");
setDeleteConfirmText("");
},
onError: (error: { message: string }) => {
toast.error(`Error deleting data: ${error.message}`);
},
});
const handleUpdateProfile = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
toast.error("Please enter your name.");
return;
}
updateProfileMutation.mutate({ name: name.trim() });
};
const handleExportData = () => {
void exportDataQuery.refetch();
};
// Type guard for backup data
const isValidBackupData = (
data: unknown,
): data is {
exportDate: string;
version: string;
user: { name?: string; email: string };
clients: Array<{
name: string;
email?: string;
phone?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
}>;
businesses: Array<{
name: string;
email?: string;
phone?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
website?: string;
taxId?: string;
logoUrl?: string;
isDefault?: boolean;
}>;
invoices: Array<{
invoiceNumber: string;
businessName?: string;
clientName: string;
issueDate: Date;
dueDate: Date;
status?: string;
totalAmount?: number;
taxRate?: number;
notes?: string;
items: Array<{
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
position?: number;
}>;
}>;
} => {
return !!(
data &&
typeof data === "object" &&
data !== null &&
"exportDate" in data &&
"version" in data &&
"user" in data &&
"clients" in data &&
"businesses" in data &&
"invoices" in data
);
};
const handleImportData = () => {
try {
const parsedData: unknown = JSON.parse(importData);
if (isValidBackupData(parsedData)) {
importDataMutation.mutate(parsedData);
} else {
toast.error("Invalid backup file format.");
}
} catch {
toast.error("Invalid JSON. Please check your backup file format.");
}
};
const handleDeleteAllData = () => {
if (deleteConfirmText !== "DELETE ALL DATA") {
toast.error("Please type 'DELETE ALL DATA' to confirm.");
return;
}
deleteDataMutation.mutate({ confirmText: deleteConfirmText });
};
// Set initial name value when profile loads
if (profile && !name && profile.name) {
setName(profile.name);
}
import { Suspense } from "react";
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";
export default async function SettingsPage() {
return (
<div className="space-y-8">
<>
<PageHeader
title="Settings"
description="Manage your account and data preferences"
variant="large-gradient"
description="Manage your account preferences and data"
variant="gradient"
/>
<div className="grid gap-8 lg:grid-cols-2">
{/* Profile Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5 text-emerald-600" />
Profile
</CardTitle>
<CardDescription>Update your personal information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={handleUpdateProfile} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your full name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
value={session?.user?.email ?? ""}
disabled
className="bg-muted"
/>
<p className="text-muted-foreground text-sm">
Email cannot be changed
</p>
</div>
<Button
type="submit"
disabled={updateProfileMutation.isPending}
className="bg-emerald-600 hover:bg-emerald-700"
>
{updateProfileMutation.isPending
? "Updating..."
: "Update Profile"}
</Button>
</form>
</CardContent>
</Card>
{/* Data Statistics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5 text-emerald-600" />
Your Data
</CardTitle>
<CardDescription>Overview of your account data</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-emerald-600">
{dataStats?.clients ?? 0}
</div>
<div className="text-muted-foreground text-sm">Clients</div>
</div>
<div>
<div className="text-2xl font-bold text-emerald-600">
{dataStats?.businesses ?? 0}
</div>
<div className="text-muted-foreground text-sm">Businesses</div>
</div>
<div>
<div className="text-2xl font-bold text-emerald-600">
{dataStats?.invoices ?? 0}
</div>
<div className="text-muted-foreground text-sm">Invoices</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Backup & Restore Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-emerald-600" />
Backup & Restore
</CardTitle>
<CardDescription>
Export your data for backup or import from a previous backup
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
{/* Export Data */}
<div className="space-y-3">
<h3 className="font-semibold">Export Data</h3>
<p className="text-muted-foreground text-sm">
Download all your clients, businesses, and invoices as a JSON
backup file.
</p>
<Button
onClick={handleExportData}
disabled={exportDataQuery.isFetching}
variant="outline"
className="w-full"
>
<Download className="mr-2 h-4 w-4" />
{exportDataQuery.isFetching ? "Exporting..." : "Export Data"}
</Button>
</div>
{/* Import Data */}
<div className="space-y-3">
<h3 className="font-semibold">Import Data</h3>
<p className="text-muted-foreground text-sm">
Restore your data from a previous backup file.
</p>
<Dialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
>
<DialogTrigger asChild>
<Button variant="outline" className="w-full">
<Upload className="mr-2 h-4 w-4" />
Import Data
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Import Backup Data</DialogTitle>
<DialogDescription>
Paste the contents of your backup JSON file below. This
will add the data to your existing account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Textarea
placeholder="Paste your backup JSON data here..."
value={importData}
onChange={(e) => setImportData(e.target.value)}
rows={10}
className="font-mono text-sm"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsImportDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleImportData}
disabled={
!importData.trim() || importDataMutation.isPending
}
className="bg-emerald-600 hover:bg-emerald-700"
>
{importDataMutation.isPending
? "Importing..."
: "Import Data"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<h4 className="font-medium text-blue-900">Backup Tips</h4>
<ul className="mt-2 space-y-1 text-sm text-blue-800">
<li> Regular backups help protect your data</li>
<li>
Backup files contain all your business data in JSON format
</li>
<li>
Import will add data to your existing account (not replace)
</li>
<li> Keep your backup files in a secure location</li>
</ul>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-red-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
Danger Zone
</CardTitle>
<CardDescription>
Irreversible actions for your account data
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<h4 className="font-medium text-red-900">Delete All Data</h4>
<p className="mt-1 text-sm text-red-800">
This will permanently delete all your clients, businesses,
invoices, and related data. This action cannot be undone.
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete All Data</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
This action cannot be undone. This will permanently delete
all your:
</p>
<ul className="list-inside list-disc space-y-1 text-sm">
<li>Clients and their information</li>
<li>Business profiles</li>
<li>Invoices and invoice items</li>
<li>All related data</li>
</ul>
<p className="font-medium">
Type{" "}
<span className="bg-muted rounded px-1 font-mono">
DELETE ALL DATA
</span>{" "}
to confirm:
</p>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="Type: DELETE ALL DATA"
className="font-mono"
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteConfirmText("")}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteAllData}
disabled={
deleteConfirmText !== "DELETE ALL DATA" ||
deleteDataMutation.isPending
}
className="bg-red-600 hover:bg-red-700"
>
{deleteDataMutation.isPending
? "Deleting..."
: "Delete All Data"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
</div>
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
<SettingsContent />
</Suspense>
</HydrateClient>
</>
);
}

View File

@@ -1,13 +1,13 @@
"use client";
import { useState } from "react";
import { DataTable } from "~/components/ui/data-table";
import { PageHeader } from "~/components/page-header";
import { DataTable } from "~/components/data/data-table";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { Plus } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTableColumnHeader } from "~/components/ui/data-table";
import { DashboardBreadcrumbs } from "~/components/dashboard-breadcrumbs";
import { DataTableColumnHeader } from "~/components/data/data-table";
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
import Link from "next/link";
// Sample data type

View File

@@ -1,5 +1,5 @@
import { Navbar } from "~/components/Navbar";
import { Sidebar } from "~/components/Sidebar";
import { Navbar } from "~/components/layout/navbar";
import { Sidebar } from "~/components/layout/sidebar";
export default function InvoicesLayout({
children,
@@ -11,10 +11,8 @@ export default function InvoicesLayout({
<Navbar />
<div className="flex">
<Sidebar />
<main className="flex-1 min-h-screen bg-background">
{children}
</main>
<main className="bg-background min-h-screen flex-1">{children}</main>
</div>
</>
);
}
}

View File

@@ -2,7 +2,7 @@ import Link from "next/link";
import { auth } from "~/server/auth";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { InvoiceList } from "~/components/invoice-list";
import { InvoiceList } from "~/components/data/invoice-list";
import { Plus } from "lucide-react";
export default async function InvoicesPage() {
@@ -12,8 +12,10 @@ export default async function InvoicesPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Access Denied</h1>
<p className="text-muted-foreground mb-8">Please sign in to view invoices</p>
<h1 className="mb-4 text-4xl font-bold">Access Denied</h1>
<p className="text-muted-foreground mb-8">
Please sign in to view invoices
</p>
<Link href="/api/auth/signin">
<Button size="lg">Sign In</Button>
</Link>
@@ -29,7 +31,7 @@ export default async function InvoicesPage() {
<HydrateClient>
<div className="p-6">
<div className="mb-8">
<h2 className="text-3xl font-bold mb-2">Invoices</h2>
<h2 className="mb-2 text-3xl font-bold">Invoices</h2>
<p className="text-muted-foreground">
Manage your invoices and payments
</p>
@@ -39,4 +41,4 @@ export default async function InvoicesPage() {
</div>
</HydrateClient>
);
}
}

View File

@@ -1,160 +1,244 @@
import Link from "next/link";
import { Button } from "~/components/ui/button";
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Logo } from "~/components/logo";
import {
Users,
FileText,
DollarSign,
CheckCircle,
ArrowRight,
Star,
Check,
Zap,
Shield,
Globe,
Sparkles,
BarChart3,
Clock,
Rocket,
Heart,
ChevronRight,
} from "lucide-react";
export default function HomePage() {
return (
<div className="bg-gradient-auth min-h-screen">
<div className="bg-background min-h-screen">
<AuthRedirect />
{/* Header */}
<header className="border-border bg-card/80 border-b backdrop-blur-sm">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
{/* Navigation */}
<nav className="bg-background/80 sticky top-0 z-50 border-b backdrop-blur-xl">
<div className="container mx-auto px-4">
<div className="flex h-16 items-center justify-between">
<Logo />
<div className="flex items-center space-x-4">
<div className="hidden items-center space-x-8 md:flex">
<a
href="#features"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Features
</a>
<a
href="#pricing"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Pricing
</a>
</div>
<div className="flex items-center space-x-3">
<Link href="/auth/signin">
<Button variant="ghost">Sign In</Button>
</Link>
<Link href="/auth/register">
<Button>Get Started</Button>
<Button className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-lg shadow-emerald-500/25 transition-all duration-300 hover:shadow-xl hover:shadow-emerald-500/30">
Get Started Free
</Button>
</Link>
</div>
</div>
</div>
</header>
</nav>
{/* Hero Section */}
<section className="px-4 py-20">
<div className="container mx-auto max-w-4xl text-center">
<h1 className="text-foreground mb-6 text-5xl font-bold md:text-6xl">
Simple Invoicing for
<span className="text-green-600"> Freelancers</span>
</h1>
<p className="text-muted-foreground mx-auto mb-8 max-w-2xl text-xl">
Create professional invoices, manage clients, and get paid faster
with beenvoice. The invoicing app that works as hard as you do.
</p>
<div className="flex flex-col justify-center gap-4 sm:flex-row">
<Link href="/auth/register">
<Button size="lg" className="px-8 py-6 text-lg">
Start Free Trial
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Link href="#features">
<Button variant="outline" size="lg" className="px-8 py-6 text-lg">
See How It Works
</Button>
</Link>
<section className="relative overflow-hidden pt-20 pb-16">
<div className="relative container mx-auto px-4 text-center">
<div className="mx-auto max-w-4xl">
<Badge
variant="secondary"
className="mb-6 border-emerald-200 bg-emerald-100 text-emerald-800"
>
<Sparkles className="mr-1 h-3 w-3" />
100% Free Forever
</Badge>
<h1 className="text-foreground mb-6 text-6xl font-bold tracking-tight sm:text-7xl lg:text-8xl">
Simple Invoicing for
<span className="block text-emerald-600">Freelancers</span>
</h1>
<p className="text-muted-foreground mx-auto mb-8 max-w-2xl text-xl leading-relaxed">
Create professional invoices, manage clients, and track payments.
Built specifically for freelancers and small businesses
<span className="font-semibold text-emerald-600">
completely free
</span>
.
</p>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
<Link href="/auth/register">
<Button
size="lg"
className="group bg-gradient-to-r from-emerald-600 to-teal-600 px-8 py-4 text-lg font-semibold shadow-xl shadow-emerald-500/25 transition-all duration-300 hover:shadow-2xl hover:shadow-emerald-500/30"
>
Start Free
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
</Button>
</Link>
<Link href="#demo">
<Button
variant="outline"
size="lg"
className="group border-slate-300 px-8 py-4 text-lg hover:border-slate-400 hover:bg-slate-50"
>
See Features
<ChevronRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
</Button>
</Link>
</div>
<div className="mt-12 flex items-center justify-center gap-8 text-sm text-slate-500">
{[
"No credit card required",
"Setup in 2 minutes",
"Cancel anytime",
].map((text, i) => (
<div key={i} className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-500" />
{text}
</div>
))}
</div>
</div>
</div>
</section>
{/* Stats */}
<section className="bg-muted/50 border-y py-12">
<div className="container mx-auto px-4">
<div className="text-center">
<p className="text-muted-foreground">
Free invoicing for independent professionals
</p>
</div>
</div>
</section>
{/* Features Section */}
<section id="features" className="bg-card px-4 py-20">
<div className="container mx-auto max-w-6xl">
<section id="features" className="py-24">
<div className="container mx-auto px-4">
<div className="mb-16 text-center">
<h2 className="text-card-foreground mb-4 text-4xl font-bold">
Everything you need to invoice like a pro
<Badge
variant="secondary"
className="mb-4 border-blue-200 bg-blue-100 text-blue-800"
>
<Zap className="mr-1 h-3 w-3" />
Supercharged Features
</Badge>
<h2 className="text-foreground mb-4 text-5xl font-bold tracking-tight">
Everything you need to
<span className="block text-emerald-600">
invoice professionally
</span>
</h2>
<p className="text-muted-foreground mx-auto max-w-2xl text-xl">
Powerful features designed for freelancers and small businesses
Simple, powerful features designed specifically for freelancers
and small businesses.
</p>
</div>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
<Card className="border-0 shadow-lg">
<CardHeader>
<Users className="mb-4 h-12 w-12 text-green-600" />
<CardTitle>Client Management</CardTitle>
<CardDescription>
Keep all your client information organized in one place
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-8 lg:grid-cols-3">
{/* Feature 1 */}
<Card className="group shadow-lg transition-all duration-300 hover:shadow-xl">
<CardContent className="p-8">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500 text-white">
<Rocket className="h-6 w-6" />
</div>
<h3 className="text-foreground mb-3 text-xl font-bold">
Quick Setup
</h3>
<p className="text-muted-foreground mb-4">
Start creating invoices immediately. No complicated setup or
configuration required.
</p>
<ul className="text-muted-foreground space-y-2 text-sm">
<li className="flex items-center">
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
Store contact details and addresses
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-500" />
Simple client management
</li>
<li className="flex items-center">
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
Track client history and invoices
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-500" />
Professional templates
</li>
<li className="flex items-center">
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
Search and filter clients easily
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-500" />
Easy invoice sending
</li>
</ul>
</CardContent>
</Card>
<Card className="border-0 shadow-lg">
<CardHeader>
<FileText className="mb-4 h-12 w-12 text-green-600" />
<CardTitle>Professional Invoices</CardTitle>
<CardDescription>
Create beautiful, detailed invoices with line items
</CardDescription>
</CardHeader>
<CardContent>
{/* Feature 2 */}
<Card className="group shadow-lg transition-all duration-300 hover:shadow-xl">
<CardContent className="p-8">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-blue-500 text-white">
<BarChart3 className="h-6 w-6" />
</div>
<h3 className="text-foreground mb-3 text-xl font-bold">
Payment Tracking
</h3>
<p className="text-muted-foreground mb-4">
Keep track of invoice status and monitor which clients have
paid.
</p>
<ul className="text-muted-foreground space-y-2 text-sm">
<li className="flex items-center">
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
Add multiple line items with dates
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-500" />
Invoice status tracking
</li>
<li className="flex items-center">
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
Automatic calculations and totals
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-500" />
Payment history
</li>
<li className="flex items-center">
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
Professional invoice numbering
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-500" />
Overdue notifications
</li>
</ul>
</CardContent>
</Card>
<Card className="border-0 shadow-lg">
<CardHeader>
<DollarSign className="mb-4 h-12 w-12 text-green-600" />
<CardTitle>Payment Tracking</CardTitle>
<CardDescription>
Monitor invoice status and track payments
</CardDescription>
</CardHeader>
<CardContent>
{/* Feature 3 */}
<Card className="group shadow-lg transition-all duration-300 hover:shadow-xl">
<CardContent className="p-8">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-purple-500 text-white">
<Globe className="h-6 w-6" />
</div>
<h3 className="text-foreground mb-3 text-xl font-bold">
Professional Features
</h3>
<p className="text-muted-foreground mb-4">
Everything you need to look professional and get paid on time.
</p>
<ul className="text-muted-foreground space-y-2 text-sm">
<li className="flex items-center">
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
Track draft, sent, paid, and overdue status
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-500" />
PDF generation
</li>
<li className="flex items-center">
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
View outstanding amounts at a glance
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-500" />
Custom tax rates
</li>
<li className="flex items-center">
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
Payment history and analytics
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-500" />
Professional numbering
</li>
</ul>
</CardContent>
@@ -163,115 +247,208 @@ export default function HomePage() {
</div>
</section>
{/* Benefits Section */}
<section className="bg-muted/50 px-4 py-20">
<div className="container mx-auto max-w-4xl text-center">
<h2 className="text-foreground mb-16 text-4xl font-bold">
Why choose beenvoice?
</h2>
{/* Pricing Section */}
<section id="pricing" className="bg-muted/50 py-24">
<div className="container mx-auto px-4">
<div className="mb-16 text-center">
<h2 className="text-foreground mb-4 text-5xl font-bold tracking-tight">
Simple, transparent pricing
</h2>
<p className="text-muted-foreground mx-auto max-w-2xl text-xl">
Start free, stay free. No hidden fees, no gotchas, no limits on
your success.
</p>
</div>
<div className="grid gap-12 md:grid-cols-2">
<div className="space-y-6">
<div className="flex items-start space-x-4">
<Zap className="mt-1 h-8 w-8 text-green-600" />
<div className="text-left">
<h3 className="text-foreground mb-2 text-xl font-semibold">
Lightning Fast
</h3>
<p className="text-muted-foreground">
Create invoices in seconds, not minutes. Our streamlined
interface gets you back to work faster.
</p>
</div>
<div className="mx-auto max-w-md">
<Card className="bg-card relative border-2 border-emerald-500 shadow-2xl">
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
<Badge className="bg-emerald-500 px-6 py-1 text-white">
Forever Free
</Badge>
</div>
<CardContent className="p-8 text-center">
<div className="mb-6">
<div className="text-foreground mb-2 text-6xl font-bold">
$0
</div>
<div className="text-muted-foreground">
per month, forever
</div>
</div>
<div className="flex items-start space-x-4">
<Shield className="mt-1 h-8 w-8 text-green-600" />
<div className="text-left">
<h3 className="text-foreground mb-2 text-xl font-semibold">
Secure & Private
</h3>
<p className="text-muted-foreground">
Your data is encrypted and secure. We never share your
information with third parties.
</p>
<div className="mb-8 space-y-4 text-left">
{[
"Unlimited invoices",
"Unlimited clients",
"Professional templates",
"PDF export",
"Payment tracking",
"Multi-business support",
"Line item details",
"Free forever",
].map((feature, i) => (
<div key={i} className="flex items-center gap-3">
<Check className="h-5 w-5 flex-shrink-0 text-emerald-500" />
<span className="text-foreground">{feature}</span>
</div>
))}
</div>
<Link href="/auth/register">
<Button className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 py-3 text-lg font-semibold shadow-lg shadow-emerald-500/25 transition-all duration-300 hover:shadow-xl hover:shadow-emerald-500/30">
Get Started Now
</Button>
</Link>
<p className="text-muted-foreground mt-4 text-sm">
No credit card required
</p>
</CardContent>
</Card>
</div>
</div>
</section>
{/* Why Choose */}
<section className="py-24">
<div className="container mx-auto px-4">
<div className="mb-16 text-center">
<h2 className="text-foreground mb-4 text-5xl font-bold tracking-tight">
Why freelancers
<span className="block text-emerald-600">choose BeenVoice</span>
</h2>
</div>
<div className="grid gap-8 md:grid-cols-3">
<div className="text-center">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500 text-white">
<Zap className="h-6 w-6" />
</div>
<h3 className="text-foreground mb-3 text-xl font-bold">
Quick & Simple
</h3>
<p className="text-muted-foreground">
No learning curve. Start creating professional invoices in
minutes, not hours.
</p>
</div>
<div className="space-y-6">
<div className="flex items-start space-x-4">
<Star className="mt-1 h-8 w-8 text-green-600" />
<div className="text-left">
<h3 className="text-foreground mb-2 text-xl font-semibold">
Professional Quality
</h3>
<p className="text-muted-foreground">
Generate invoices that look professional and build trust
with your clients.
</p>
</div>
<div className="text-center">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-blue-500 text-white">
<Shield className="h-6 w-6" />
</div>
<div className="flex items-start space-x-4">
<Clock className="mt-1 h-8 w-8 text-green-600" />
<div className="text-left">
<h3 className="text-foreground mb-2 text-xl font-semibold">
Save Time
</h3>
<p className="text-muted-foreground">
Automated calculations, templates, and client management
save you hours every month.
</p>
</div>
<h3 className="text-foreground mb-3 text-xl font-bold">
Always Free
</h3>
<p className="text-muted-foreground">
No hidden fees, no premium tiers. All features are free for as
long as you need them.
</p>
</div>
<div className="text-center">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-purple-500 text-white">
<Clock className="h-6 w-6" />
</div>
<h3 className="text-foreground mb-3 text-xl font-bold">
Save Time
</h3>
<p className="text-muted-foreground">
Focus on your work, not paperwork. Automated calculations and
professional formatting.
</p>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="bg-green-600 px-4 py-20">
<div className="container mx-auto max-w-2xl text-center">
<h2 className="mb-4 text-4xl font-bold text-white">
Ready to get started?
</h2>
<p className="mb-8 text-xl text-green-100">
Join thousands of freelancers who trust beenvoice for their
invoicing needs.
</p>
<Link href="/auth/register">
<Button size="lg" variant="secondary" className="px-8 py-6 text-lg">
Start Your Free Trial
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<p className="mt-4 text-sm text-green-200">
No credit card required Cancel anytime
</p>
<section className="relative overflow-hidden bg-gradient-to-br from-emerald-600 via-emerald-700 to-teal-800 py-24">
<div className="absolute inset-0 bg-gradient-to-br from-emerald-600/90 to-teal-800/90"></div>
<div className="absolute top-10 left-10 h-64 w-64 rounded-full bg-white/10 blur-3xl"></div>
<div className="absolute right-10 bottom-10 h-80 w-80 rounded-full bg-white/5 blur-3xl"></div>
<div className="relative container mx-auto px-4 text-center">
<div className="mx-auto max-w-3xl">
<h2 className="mb-6 text-5xl font-bold text-white">
Ready to revolutionize
<span className="block">your invoicing?</span>
</h2>
<p className="mb-8 text-xl text-emerald-100">
Join thousands of entrepreneurs who&apos;ve already transformed
their business with BeenVoice. Start your journey
today&mdash;completely free.
</p>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
<Link href="/auth/register">
<Button
size="lg"
variant="secondary"
className="group bg-white px-8 py-4 text-lg font-semibold text-emerald-700 shadow-xl transition-all duration-300 hover:bg-gray-50 hover:shadow-2xl"
>
Start Your Success Story
<Rocket className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
</Button>
</Link>
</div>
<div className="mt-8 flex items-center justify-center gap-8 text-emerald-200">
<div className="flex items-center gap-2">
<Heart className="h-4 w-4" />
Free forever
</div>
<div className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Secure & private
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
2-minute setup
</div>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-card text-card-foreground border-border border-t px-4 py-12">
<div className="container mx-auto text-center">
<Logo className="mx-auto mb-4" />
<p className="text-muted-foreground mb-4">
Simple invoicing for freelancers and small businesses
</p>
<div className="text-muted-foreground flex justify-center space-x-6 text-sm">
<Link
href="/auth/signin"
className="hover:text-foreground transition-colors"
>
Sign In
</Link>
<Link
href="/auth/register"
className="hover:text-foreground transition-colors"
>
Register
</Link>
<footer className="bg-background border-t py-12">
<div className="container mx-auto px-4">
<div className="text-center">
<Logo className="mx-auto mb-4" />
<p className="text-muted-foreground mb-6">
Simple invoicing for freelancers. Free, forever.
</p>
<div className="text-muted-foreground flex items-center justify-center gap-8 text-sm">
<Link
href="/auth/signin"
className="hover:text-foreground transition-colors"
>
Sign In
</Link>
<Link
href="/auth/register"
className="hover:text-foreground transition-colors"
>
Get Started
</Link>
<a
href="#features"
className="hover:text-foreground transition-colors"
>
Features
</a>
<a
href="#pricing"
className="hover:text-foreground transition-colors"
>
Pricing
</a>
</div>
<div className="mt-8 border-t pt-8">
<p className="text-muted-foreground">
&copy; 2024 BeenVoice. Built with &hearts; for entrepreneurs.
</p>
</div>
</div>
</div>
</footer>

View File

@@ -24,7 +24,7 @@ import {
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { FileUpload } from "~/components/ui/file-upload";
import { FileUpload } from "~/components/forms/file-upload";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Progress } from "~/components/ui/progress";

View File

@@ -21,7 +21,7 @@ import {
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
import { toast } from "sonner";
import {
FileText,

View File

@@ -6,7 +6,7 @@ import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
import { Separator } from "~/components/ui/separator";
import {
Dialog,

View File

@@ -1,15 +1,27 @@
import * as React from "react";
import { Badge, type badgeVariants } from "./badge";
import { Badge, type badgeVariants } from "~/components/ui/badge";
import { type VariantProps } from "class-variance-authority";
type StatusType = "draft" | "sent" | "paid" | "overdue" | "success" | "warning" | "error" | "info";
type StatusType =
| "draft"
| "sent"
| "paid"
| "overdue"
| "success"
| "warning"
| "error"
| "info";
interface StatusBadgeProps extends Omit<React.ComponentProps<typeof Badge>, "variant"> {
interface StatusBadgeProps
extends Omit<React.ComponentProps<typeof Badge>, "variant"> {
status: StatusType;
children?: React.ReactNode;
}
const statusVariantMap: Record<StatusType, VariantProps<typeof badgeVariants>["variant"]> = {
const statusVariantMap: Record<
StatusType,
VariantProps<typeof badgeVariants>["variant"]
> = {
draft: "secondary",
sent: "info",
paid: "success",

View File

@@ -22,8 +22,8 @@ import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { FormSkeleton } from "~/components/ui/skeleton";
import { Switch } from "~/components/ui/switch";
import { AddressForm } from "~/components/ui/address-form";
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { api } from "~/trpc/react";
import {
formatPhoneNumber,

View File

@@ -10,8 +10,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { FormSkeleton } from "~/components/ui/skeleton";
import { AddressForm } from "~/components/ui/address-form";
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { api } from "~/trpc/react";
import {
formatPhoneNumber,

View File

@@ -5,7 +5,7 @@ import { useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { cn } from "~/lib/utils";
import { Upload, FileText, X, CheckCircle, AlertCircle } from "lucide-react";
import { Button } from "./button";
import { Button } from "~/components/ui/button";
interface FileUploadProps {
onFilesSelected: (files: File[]) => void;
@@ -25,7 +25,12 @@ interface FilePreviewProps {
error?: string;
}
function FilePreview({ file, onRemove, status = "pending", error }: FilePreviewProps) {
function FilePreview({
file,
onRemove,
status = "pending",
error,
}: FilePreviewProps) {
const getStatusIcon = () => {
switch (status) {
case "success":
@@ -49,20 +54,22 @@ function FilePreview({ file, onRemove, status = "pending", error }: FilePreviewP
};
return (
<div className={cn(
"flex items-center justify-between p-3 rounded-lg border",
getStatusColor()
)}>
<div
className={cn(
"flex items-center justify-between rounded-lg border p-3",
getStatusColor(),
)}
>
<div className="flex items-center gap-3">
{getStatusIcon()}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{file.name}</p>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900">
{file.name}
</p>
<p className="text-xs text-gray-500">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
{error && (
<p className="text-xs text-red-600 mt-1">{error}</p>
)}
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
</div>
</div>
<Button
@@ -85,99 +92,111 @@ export function FileUpload({
className,
disabled = false,
placeholder = "Drag & drop files here, or click to select",
description
description,
}: FileUploadProps) {
const [files, setFiles] = React.useState<File[]>([]);
const [errors, setErrors] = React.useState<Record<string, string>>({});
const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: any[]) => {
// Handle accepted files
const newFiles = [...files, ...acceptedFiles];
setFiles(newFiles);
onFilesSelected(newFiles);
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: any[]) => {
// Handle accepted files
const newFiles = [...files, ...acceptedFiles];
setFiles(newFiles);
onFilesSelected(newFiles);
// Handle rejected files
const newErrors: Record<string, string> = { ...errors };
rejectedFiles.forEach(({ file, errors }) => {
const errorMessage = errors.map((e: any) => {
if (e.code === 'file-too-large') {
return `File is too large. Max size is ${(maxSize / 1024 / 1024).toFixed(1)}MB`;
}
if (e.code === 'file-invalid-type') {
return 'File type not supported';
}
if (e.code === 'too-many-files') {
return `Too many files. Max is ${maxFiles}`;
}
return e.message;
}).join(', ');
newErrors[file.name] = errorMessage;
});
setErrors(newErrors);
}, [files, onFilesSelected, errors, maxFiles, maxSize]);
// Handle rejected files
const newErrors: Record<string, string> = { ...errors };
rejectedFiles.forEach(({ file, errors }) => {
const errorMessage = errors
.map((e: any) => {
if (e.code === "file-too-large") {
return `File is too large. Max size is ${(maxSize / 1024 / 1024).toFixed(1)}MB`;
}
if (e.code === "file-invalid-type") {
return "File type not supported";
}
if (e.code === "too-many-files") {
return `Too many files. Max is ${maxFiles}`;
}
return e.message;
})
.join(", ");
newErrors[file.name] = errorMessage;
});
setErrors(newErrors);
},
[files, onFilesSelected, errors, maxFiles, maxSize],
);
const removeFile = (fileToRemove: File) => {
const newFiles = files.filter(file => file !== fileToRemove);
const newFiles = files.filter((file) => file !== fileToRemove);
setFiles(newFiles);
onFilesSelected(newFiles);
const newErrors = { ...errors };
delete newErrors[fileToRemove.name];
setErrors(newErrors);
};
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
onDrop,
accept,
maxFiles,
maxSize,
disabled
});
const { getRootProps, getInputProps, isDragActive, isDragReject } =
useDropzone({
onDrop,
accept,
maxFiles,
maxSize,
disabled,
});
return (
<div className={cn("space-y-4", className)}>
<div
{...getRootProps()}
className={cn(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer",
"cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors",
"hover:border-emerald-400 hover:bg-emerald-50/50",
isDragActive && "border-emerald-400 bg-emerald-50/50",
isDragReject && "border-red-400 bg-red-50/50",
disabled && "opacity-50 cursor-not-allowed",
"bg-white/80 backdrop-blur-sm"
disabled && "cursor-not-allowed opacity-50",
"bg-white/80 backdrop-blur-sm",
)}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center gap-4">
<div className={cn(
"p-3 rounded-full transition-colors",
isDragActive ? "bg-emerald-100" : "bg-gray-100",
isDragReject && "bg-red-100"
)}>
<Upload className={cn(
"h-6 w-6 transition-colors",
isDragActive ? "text-emerald-600" : "text-gray-400",
isDragReject && "text-red-600"
)} />
<div
className={cn(
"rounded-full p-3 transition-colors",
isDragActive ? "bg-emerald-100" : "bg-gray-100",
isDragReject && "bg-red-100",
)}
>
<Upload
className={cn(
"h-6 w-6 transition-colors",
isDragActive ? "text-emerald-600" : "text-gray-400",
isDragReject && "text-red-600",
)}
/>
</div>
<div className="space-y-2">
<p className={cn(
"text-lg font-medium transition-colors",
isDragActive ? "text-emerald-600" : "text-gray-900",
isDragReject && "text-red-600"
)}>
{isDragActive
? isDragReject
? "File type not supported"
<p
className={cn(
"text-lg font-medium transition-colors",
isDragActive ? "text-emerald-600" : "text-gray-900",
isDragReject && "text-red-600",
)}
>
{isDragActive
? isDragReject
? "File type not supported"
: "Drop files here"
: placeholder
}
: placeholder}
</p>
{description && (
<p className="text-sm text-gray-500">{description}</p>
)}
<p className="text-xs text-gray-400">
Max {maxFiles} file{maxFiles !== 1 ? 's' : ''} {(maxSize / 1024 / 1024).toFixed(1)}MB each
Max {maxFiles} file{maxFiles !== 1 ? "s" : ""} {" "}
{(maxSize / 1024 / 1024).toFixed(1)}MB each
</p>
</div>
</div>
@@ -187,7 +206,7 @@ export function FileUpload({
{files.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-700">Selected Files</h4>
<div className="space-y-2 max-h-60 overflow-y-auto">
<div className="max-h-60 space-y-2 overflow-y-auto">
{files.map((file, index) => (
<FilePreview
key={`${file.name}-${index}`}
@@ -203,16 +222,20 @@ export function FileUpload({
{/* Error Summary */}
{Object.keys(errors).length > 0 && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-600" />
<span className="text-sm font-medium text-red-800">Upload Errors</span>
<span className="text-sm font-medium text-red-800">
Upload Errors
</span>
</div>
<ul className="text-sm text-red-700 space-y-1">
<ul className="space-y-1 text-sm text-red-700">
{Object.entries(errors).map(([fileName, error]) => (
<li key={fileName} className="flex items-start gap-2">
<span className="text-red-600"></span>
<span><strong>{fileName}:</strong> {error}</span>
<span>
<strong>{fileName}:</strong> {error}
</span>
</li>
))}
</ul>
@@ -220,4 +243,4 @@ export function FileUpload({
)}
</div>
);
}
}

View File

@@ -36,7 +36,7 @@ import {
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { FormSkeleton } from "~/components/ui/skeleton";
import { EditableInvoiceItems } from "~/components/editable-invoice-items";
import { EditableInvoiceItems } from "~/components/data/editable-invoice-items";
const STATUS_OPTIONS = [
{
@@ -273,16 +273,16 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return (
<div className="space-y-6 pb-20">
{/* Invoice Details Card Skeleton */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<Card className="shadow-lg">
<CardHeader>
<div className="h-6 w-48 animate-pulse rounded bg-gray-300 dark:bg-gray-600"></div>
<div className="h-6 w-48 animate-pulse rounded bg-gray-300"></div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6 xl:grid-cols-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-gray-300 dark:bg-gray-600"></div>
<div className="h-10 animate-pulse rounded bg-gray-300 dark:bg-gray-600"></div>
<div className="h-4 w-24 animate-pulse rounded bg-gray-300"></div>
<div className="h-10 animate-pulse rounded bg-gray-300"></div>
</div>
))}
</div>
@@ -290,20 +290,20 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</Card>
{/* Invoice Items Card Skeleton */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center justify-between">
<div className="h-6 w-32 animate-pulse rounded bg-gray-300 dark:bg-gray-600"></div>
<div className="h-10 w-24 animate-pulse rounded bg-gray-300 dark:bg-gray-600"></div>
<div className="h-6 w-32 animate-pulse rounded bg-gray-300"></div>
<div className="h-10 w-24 animate-pulse rounded bg-gray-300"></div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Items Table Header Skeleton */}
<div className="grid grid-cols-12 gap-2 rounded-lg bg-gray-50 px-4 py-3 dark:bg-gray-700">
<div className="grid grid-cols-12 gap-2 rounded-lg bg-gray-50 px-4 py-3">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="h-4 animate-pulse rounded bg-gray-300 dark:bg-gray-600"
className="h-4 animate-pulse rounded bg-gray-300"
></div>
))}
</div>
@@ -313,7 +313,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="grid animate-pulse grid-cols-12 items-center gap-2 rounded-lg border border-gray-200 p-4 dark:border-gray-700"
className="grid animate-pulse grid-cols-12 items-center gap-2 rounded-lg border border-gray-200 p-4"
>
{Array.from({ length: 8 }).map((_, j) => (
<div
@@ -353,7 +353,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return (
<div className="space-y-6 pb-20">
{/* Invoice Details Card Skeleton */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
<Card className="shadow-lg">
<CardHeader>
<div className="h-6 w-48 animate-pulse rounded bg-gray-300"></div>
</CardHeader>
@@ -370,7 +370,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</Card>
{/* Invoice Items Card Skeleton */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center justify-between">
<div className="h-6 w-32 animate-pulse rounded bg-gray-300"></div>
@@ -423,9 +423,9 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return (
<form id="invoice-form" onSubmit={handleSubmit} className="space-y-6 pb-20">
{/* Invoice Details Card */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
<CardTitle className="flex items-center gap-2 text-emerald-700">
<FileText className="h-5 w-5" />
Invoice Details
</CardTitle>
@@ -653,10 +653,10 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</Card>
{/* Invoice Items Card */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
<CardTitle className="flex items-center gap-2 text-emerald-700">
<Clock className="h-5 w-5" />
Invoice Items
</CardTitle>

View File

@@ -4,8 +4,8 @@ import { useSession, signOut } from "next-auth/react";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { Logo } from "./logo";
import { SidebarTrigger } from "./SidebarTrigger";
import { Logo } from "~/components/branding/logo";
import { SidebarTrigger } from "~/components/navigation/sidebar-trigger";
export function Navbar() {
const { data: session, status } = useSession();

View File

@@ -1,7 +1,12 @@
import { z } from "zod";
import { eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { invoices, invoiceItems, clients, businesses } from "~/server/db/schema";
import {
invoices,
invoiceItems,
clients,
businesses,
} from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
const invoiceItemSchema = z.object({
@@ -35,15 +40,15 @@ const updateStatusSchema = z.object({
export const invoicesRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
try {
return await ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, ctx.session.user.id),
with: {
business: true,
client: true,
items: true,
},
orderBy: (invoices, { desc }) => [desc(invoices.createdAt)],
});
return await ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, ctx.session.user.id),
with: {
business: true,
client: true,
items: true,
},
orderBy: (invoices, { desc }) => [desc(invoices.issueDate)],
});
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
@@ -58,11 +63,11 @@ export const invoicesRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
try {
const invoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.id, input.id),
with: {
business: true,
client: true,
items: {
where: eq(invoices.id, input.id),
with: {
business: true,
client: true,
items: {
orderBy: (items, { asc }) => [asc(items.position)],
},
},
@@ -90,7 +95,7 @@ export const invoicesRouter = createTRPCRouter({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch invoice",
cause: error,
});
});
}
}),
@@ -98,8 +103,8 @@ export const invoicesRouter = createTRPCRouter({
.input(createInvoiceSchema)
.mutation(async ({ ctx, input }) => {
try {
const { items, ...invoiceData } = input;
const { items, ...invoiceData } = input;
// Verify business exists and belongs to user (if provided)
if (invoiceData.businessId) {
const business = await ctx.db.query.businesses.findFirst({
@@ -116,11 +121,12 @@ export const invoicesRouter = createTRPCRouter({
if (business.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to create invoices for this business",
message:
"You don't have permission to create invoices for this business",
});
}
}
// Verify client exists and belongs to user
const client = await ctx.db.query.clients.findFirst({
where: eq(clients.id, invoiceData.clientId),
@@ -136,40 +142,47 @@ export const invoicesRouter = createTRPCRouter({
if (client.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to create invoices for this client",
message:
"You don't have permission to create invoices for this client",
});
}
// Calculate subtotal and tax
const subtotal = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
const taxAmount = (subtotal * invoiceData.taxRate) / 100;
const totalAmount = subtotal + taxAmount;
// Create invoice
const [invoice] = await ctx.db.insert(invoices).values({
...invoiceData,
totalAmount,
createdById: ctx.session.user.id,
}).returning();
// Calculate subtotal and tax
const subtotal = items.reduce(
(sum, item) => sum + item.hours * item.rate,
0,
);
const taxAmount = (subtotal * invoiceData.taxRate) / 100;
const totalAmount = subtotal + taxAmount;
if (!invoice) {
// Create invoice
const [invoice] = await ctx.db
.insert(invoices)
.values({
...invoiceData,
totalAmount,
createdById: ctx.session.user.id,
})
.returning();
if (!invoice) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create invoice",
});
}
}
// Create invoice items
// Create invoice items
const itemsToInsert = items.map((item, idx) => ({
...item,
invoiceId: invoice.id,
amount: item.hours * item.rate,
...item,
invoiceId: invoice.id,
amount: item.hours * item.rate,
position: idx,
}));
}));
await ctx.db.insert(invoiceItems).values(itemsToInsert);
await ctx.db.insert(invoiceItems).values(itemsToInsert);
return invoice;
return invoice;
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
@@ -184,8 +197,8 @@ export const invoicesRouter = createTRPCRouter({
.input(updateInvoiceSchema)
.mutation(async ({ ctx, input }) => {
try {
const { id, items, ...invoiceData } = input;
const { id, items, ...invoiceData } = input;
// Verify invoice exists and belongs to user
const existingInvoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.id, id),
@@ -232,46 +245,52 @@ export const invoicesRouter = createTRPCRouter({
});
}
}
if (items) {
// Calculate subtotal and tax
const subtotal = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
const taxAmount = (subtotal * (invoiceData.taxRate ?? existingInvoice.taxRate)) / 100;
const totalAmount = subtotal + taxAmount;
// Update invoice
await ctx.db
.update(invoices)
.set({
...invoiceData,
totalAmount,
updatedAt: new Date(),
})
.where(eq(invoices.id, id));
// Delete existing items and create new ones
await ctx.db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
if (items) {
// Calculate subtotal and tax
const subtotal = items.reduce(
(sum, item) => sum + item.hours * item.rate,
0,
);
const taxAmount =
(subtotal * (invoiceData.taxRate ?? existingInvoice.taxRate)) / 100;
const totalAmount = subtotal + taxAmount;
// Update invoice
await ctx.db
.update(invoices)
.set({
...invoiceData,
totalAmount,
updatedAt: new Date(),
})
.where(eq(invoices.id, id));
// Delete existing items and create new ones
await ctx.db
.delete(invoiceItems)
.where(eq(invoiceItems.invoiceId, id));
const itemsToInsert = items.map((item, idx) => ({
...item,
invoiceId: id,
amount: item.hours * item.rate,
...item,
invoiceId: id,
amount: item.hours * item.rate,
position: idx,
}));
}));
await ctx.db.insert(invoiceItems).values(itemsToInsert);
} else {
// Update invoice without items
await ctx.db
.update(invoices)
.set({
...invoiceData,
updatedAt: new Date(),
})
.where(eq(invoices.id, id));
}
await ctx.db.insert(invoiceItems).values(itemsToInsert);
} else {
// Update invoice without items
await ctx.db
.update(invoices)
.set({
...invoiceData,
updatedAt: new Date(),
})
.where(eq(invoices.id, id));
}
return { success: true };
return { success: true };
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
@@ -305,9 +324,9 @@ export const invoicesRouter = createTRPCRouter({
});
}
// Items will be deleted automatically due to cascade
// Items will be deleted automatically due to cascade
await ctx.db.delete(invoices).where(eq(invoices.id, input.id));
return { success: true };
} catch (error) {
if (error instanceof TRPCError) throw error;
@@ -343,12 +362,12 @@ export const invoicesRouter = createTRPCRouter({
}
await ctx.db
.update(invoices)
.set({
status: input.status,
updatedAt: new Date(),
})
.where(eq(invoices.id, input.id));
.update(invoices)
.set({
status: input.status,
updatedAt: new Date(),
})
.where(eq(invoices.id, input.id));
return { success: true };
} catch (error) {
@@ -360,4 +379,4 @@ export const invoicesRouter = createTRPCRouter({
});
}
}),
});
});

View File

@@ -107,19 +107,19 @@
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(0.145 0 0);
--background: oklch(0.145 0.02 160);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card: oklch(0.205 0.02 160);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover: oklch(0.205 0.02 160);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary: oklch(0.269 0.015 160);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted: oklch(0.269 0.015 160);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent: oklch(0.269 0.015 160);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
@@ -131,11 +131,11 @@
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar: oklch(0.205 0.02 160);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent: oklch(0.269 0.015 160);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);