mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-14 01:54:43 -05:00
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:
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
532
src/app/dashboard/settings/_components/settings-content.tsx
Normal file
532
src/app/dashboard/settings/_components/settings-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
565
src/app/page.tsx
565
src/app/page.tsx
@@ -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've already transformed
|
||||
their business with BeenVoice. Start your journey
|
||||
today—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">
|
||||
© 2024 BeenVoice. Built with ♥ for entrepreneurs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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",
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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({
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user