mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 17:48:55 -04:00
feat: polish invoice editor and viewer UI with custom NumberInput
component - Create custom NumberInput component with increment/decrement buttons - Add 0.25 step increments for hours and rates in invoice forms - Implement emerald-themed styling with hover states and accessibility - Add keyboard navigation (arrow keys) and proper ARIA support - Condense invoice editor tax/totals section into efficient grid layout - Update client dropdown to single-line format (name + email) - Add fixed footer with floating action bar pattern matching business forms - Redesign invoice viewer with better space utilization and visual hierarchy - Maintain professional appearance and consistent design system - Fix Next.js 15 params Promise handling across all invoice pages - Resolve TypeScript compilation errors and type-only imports
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
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,
|
||||
@@ -44,60 +45,60 @@ export function DashboardStats() {
|
||||
|
||||
return (
|
||||
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl dark:bg-gray-800/80">
|
||||
<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-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
Total Clients
|
||||
</CardTitle>
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Users className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
<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 dark:text-emerald-400">
|
||||
<div className="text-3xl font-bold text-emerald-600">
|
||||
{totalClients}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{totalClients > lastMonthClients ? "+" : ""}
|
||||
{totalClients - lastMonthClients} from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl dark:bg-gray-800/80">
|
||||
<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-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
Total Invoices
|
||||
</CardTitle>
|
||||
<div className="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<FileText className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<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 dark:text-blue-400">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{totalInvoices}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{totalInvoices > lastMonthInvoices ? "+" : ""}
|
||||
{totalInvoices - lastMonthInvoices} from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl dark:bg-gray-800/80">
|
||||
<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-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
Revenue
|
||||
</CardTitle>
|
||||
<div className="rounded-lg bg-teal-100 p-2 dark:bg-teal-900/30">
|
||||
<TrendingUp className="h-4 w-4 text-teal-600 dark:text-teal-400" />
|
||||
<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 dark:text-teal-400">
|
||||
<div className="text-3xl font-bold text-teal-600">
|
||||
${totalRevenue.toFixed(2)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{totalRevenue > lastMonthRevenue ? "+" : ""}
|
||||
{(
|
||||
((totalRevenue - lastMonthRevenue) / (lastMonthRevenue || 1)) *
|
||||
@@ -108,22 +109,20 @@ export function DashboardStats() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl dark:bg-gray-800/80">
|
||||
<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-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
Pending Invoices
|
||||
</CardTitle>
|
||||
<div className="rounded-lg bg-orange-100 p-2 dark:bg-orange-900/30">
|
||||
<Calendar className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||
<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 dark:text-orange-400">
|
||||
<div className="text-3xl font-bold text-orange-600">
|
||||
{pendingInvoices}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Due this month
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">Due this month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -134,34 +133,27 @@ export function DashboardStats() {
|
||||
export function DashboardCards() {
|
||||
return (
|
||||
<div className="mb-8 grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl dark:bg-gray-800/80">
|
||||
<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 dark:text-emerald-400">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<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-gray-600 dark:text-gray-300">
|
||||
<p className="text-muted-foreground">
|
||||
Add new clients and manage your existing client relationships.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
||||
>
|
||||
<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="border-gray-300 font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
<Button variant="outline" asChild className="font-medium">
|
||||
<Link href="/dashboard/clients">
|
||||
View All Clients
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
@@ -171,34 +163,27 @@ export function DashboardCards() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl dark:bg-gray-800/80">
|
||||
<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 dark:text-emerald-400">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<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-gray-600 dark:text-gray-300">
|
||||
<p className="text-muted-foreground">
|
||||
Generate professional invoices and track payments.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
||||
>
|
||||
<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="border-gray-300 font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
<Button variant="outline" asChild className="font-medium">
|
||||
<Link href="/dashboard/invoices">
|
||||
View All Invoices
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
@@ -222,22 +207,20 @@ export function DashboardActivity() {
|
||||
const recentInvoices = invoices?.slice(0, 5) ?? [];
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-emerald-700 dark:text-emerald-400">
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
<CardTitle className="text-emerald-700">Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentInvoices.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 p-4 dark:bg-gray-700">
|
||||
<FileText className="h-8 w-8 text-gray-400 dark:text-gray-500" />
|
||||
<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="mb-2 text-lg font-medium dark:text-gray-300">
|
||||
<p className="text-foreground mb-2 text-lg font-medium">
|
||||
No recent activity
|
||||
</p>
|
||||
<p className="text-sm dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Start by adding your first client or creating an invoice
|
||||
</p>
|
||||
</div>
|
||||
@@ -246,37 +229,24 @@ export function DashboardActivity() {
|
||||
{recentInvoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="flex items-center justify-between rounded-lg bg-gray-50 p-4 dark:bg-gray-700"
|
||||
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 dark:bg-emerald-900/30">
|
||||
<FileText className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
<div className="rounded-lg bg-emerald-100 p-2">
|
||||
<FileText className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
<p className="text-foreground font-medium">
|
||||
Invoice #{invoice.invoiceNumber}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<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">
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
||||
invoice.status === "paid"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
: invoice.status === "sent"
|
||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
: invoice.status === "overdue"
|
||||
? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{invoice.status.charAt(0).toUpperCase() +
|
||||
invoice.status.slice(1)}
|
||||
</span>
|
||||
<StatusBadge status={invoice.status as StatusType} />
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { BusinessForm } from "~/components/business-form";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { BusinessForm } from "~/components/business-form";
|
||||
import { PageHeader } from "~/components/page-header";
|
||||
|
||||
export default function EditBusinessPage() {
|
||||
const params = useParams();
|
||||
const businessId = Array.isArray(params?.id) ? params.id[0] : params?.id;
|
||||
if (!businessId) return null;
|
||||
return <BusinessForm businessId={businessId} mode="edit" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Edit Business"
|
||||
description="Update business information below."
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<BusinessForm businessId={businessId} mode="edit" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { notFound } from "next/navigation";
|
||||
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 Link from "next/link";
|
||||
import {
|
||||
Edit,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Building,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Globe,
|
||||
Hash,
|
||||
} from "lucide-react";
|
||||
|
||||
interface BusinessDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function BusinessDetailPage({
|
||||
params,
|
||||
}: BusinessDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const business = await api.businesses.getById({ id });
|
||||
|
||||
if (!business) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<PageHeader
|
||||
title={business.name}
|
||||
description="Business Details"
|
||||
variant="gradient"
|
||||
>
|
||||
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
||||
<Button className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Business
|
||||
</Button>
|
||||
</Link>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Business Information Card */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="border-0 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-green-600">
|
||||
<Building className="h-5 w-5" />
|
||||
<span>Business Information</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{business.email && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Mail className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Email
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{business.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{business.phone && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Phone className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Phone
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{business.phone}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{business.website && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Globe className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Website
|
||||
</p>
|
||||
<a
|
||||
href={business.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground text-sm hover:text-emerald-600 hover:underline"
|
||||
>
|
||||
{business.website}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{business.taxId && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Hash className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Tax ID
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{business.taxId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{(business.addressLine1 ?? business.city ?? business.state) && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<MapPin className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Address
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-foreground ml-11 space-y-1 text-sm">
|
||||
{business.addressLine1 && <p>{business.addressLine1}</p>}
|
||||
{business.addressLine2 && <p>{business.addressLine2}</p>}
|
||||
{(business.city ?? business.state ?? business.postalCode) && (
|
||||
<p>
|
||||
{[business.city, business.state, business.postalCode]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{business.country && <p>{business.country}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Business Since */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Calendar className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Business Added
|
||||
</p>
|
||||
<p className="text-sm dark:text-gray-300">
|
||||
{formatDate(business.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Business Badge */}
|
||||
{business.isDefault && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Building className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Status
|
||||
</p>
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400">
|
||||
Default Business
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Settings & Actions Card */}
|
||||
<div className="space-y-6">
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
|
||||
<Building className="h-5 w-5" />
|
||||
<span>Business Settings</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Default Business
|
||||
</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{business.isDefault ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400">
|
||||
Yes
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">No</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Quick Actions
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Business
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<DollarSign className="mr-2 h-4 w-4" />
|
||||
Create Invoice
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Information Card */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg dark:text-white">
|
||||
About This Business
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p>
|
||||
This business profile is used for generating invoices and
|
||||
represents your company information to clients.
|
||||
</p>
|
||||
{business.isDefault && (
|
||||
<p className="text-emerald-600 dark:text-emerald-400">
|
||||
This is your default business and will be automatically
|
||||
selected when creating new invoices.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
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 { Building, Pencil, Trash2, ExternalLink } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Type for business data
|
||||
interface Business {
|
||||
id: string;
|
||||
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;
|
||||
logoUrl: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
interface BusinessesDataTableProps {
|
||||
businesses: Business[];
|
||||
}
|
||||
|
||||
const formatAddress = (business: Business) => {
|
||||
const parts = [
|
||||
business.addressLine1,
|
||||
business.addressLine2,
|
||||
business.city,
|
||||
business.state,
|
||||
business.postalCode,
|
||||
].filter(Boolean);
|
||||
return parts.join(", ") || "—";
|
||||
};
|
||||
|
||||
export function BusinessesDataTable({
|
||||
businesses: initialBusinesses,
|
||||
}: BusinessesDataTableProps) {
|
||||
const [businesses, setBusinesses] = useState(initialBusinesses);
|
||||
const [businessToDelete, setBusinessToDelete] = useState<Business | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteBusinessMutation = api.businesses.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Business deleted successfully");
|
||||
setBusinesses(businesses.filter((b) => b.id !== businessToDelete?.id));
|
||||
setBusinessToDelete(null);
|
||||
void utils.businesses.getAll.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete business: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!businessToDelete) return;
|
||||
deleteBusinessMutation.mutate({ id: businessToDelete.id });
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Business>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const business = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-status-info-muted hidden rounded-lg p-2 sm:flex">
|
||||
<Building className="text-status-info h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{business.name}</p>
|
||||
<p className="text-muted-foreground truncate text-sm">
|
||||
{business.email ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "phone",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Phone" />
|
||||
),
|
||||
cell: ({ row }) => row.original.phone ?? "—",
|
||||
meta: {
|
||||
headerClassName: "hidden md:table-cell",
|
||||
cellClassName: "hidden md:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "address",
|
||||
header: "Address",
|
||||
cell: ({ row }) => formatAddress(row.original),
|
||||
meta: {
|
||||
headerClassName: "hidden lg:table-cell",
|
||||
cellClassName: "hidden lg:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "taxId",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Tax ID" />
|
||||
),
|
||||
cell: ({ row }) => row.original.taxId ?? "—",
|
||||
meta: {
|
||||
headerClassName: "hidden xl:table-cell",
|
||||
cellClassName: "hidden xl:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "website",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Website" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const website = row.original.website;
|
||||
if (!website) return "—";
|
||||
|
||||
// Add https:// if not present
|
||||
const url = website.startsWith("http") ? website : `https://${website}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: Show full URL */}
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hidden hover:underline sm:inline"
|
||||
>
|
||||
{website}
|
||||
</a>
|
||||
{/* Mobile: Show link button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 sm:hidden"
|
||||
asChild
|
||||
>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const business = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setBusinessToDelete(business)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={businesses}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search businesses..."
|
||||
/>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog
|
||||
open={!!businessToDelete}
|
||||
onOpenChange={(open) => !open && setBusinessToDelete(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
business "{businessToDelete?.name}" and remove all associated
|
||||
data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setBusinessToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteBusinessMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { UniversalTable } from "~/components/ui/universal-table";
|
||||
import { TableSkeleton } from "~/components/ui/skeleton";
|
||||
import { DataTableSkeleton } from "~/components/ui/data-table";
|
||||
import { BusinessesDataTable } from "./businesses-data-table";
|
||||
|
||||
export function BusinessesTable() {
|
||||
const { isLoading } = api.businesses.getAll.useQuery();
|
||||
const { data: businesses, isLoading } = api.businesses.getAll.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton rows={8} />;
|
||||
return <DataTableSkeleton columns={6} rows={8} />;
|
||||
}
|
||||
|
||||
return <UniversalTable resource="businesses" />;
|
||||
}
|
||||
if (!businesses) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <BusinessesDataTable businesses={businesses} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import Link from "next/link";
|
||||
import { BusinessForm } from "~/components/business-form";
|
||||
|
||||
import { PageHeader } from "~/components/page-header";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
|
||||
export default function NewBusinessPage() {
|
||||
return <BusinessForm mode="create" />;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Add Business"
|
||||
description="Enter business details below to add a new business."
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<HydrateClient>
|
||||
<BusinessForm mode="create" />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
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";
|
||||
|
||||
export default async function BusinessesPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent dark:from-emerald-400 dark:to-teal-400">
|
||||
Businesses
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600 dark:text-gray-300">
|
||||
Manage your businesses and their information.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
||||
>
|
||||
<>
|
||||
<PageHeader
|
||||
title="Businesses"
|
||||
description="Manage your businesses and their information."
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild variant="brand">
|
||||
<Link href="/dashboard/businesses/new">
|
||||
<Plus className="mr-2 h-5 w-5" /> Add Business
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>Add Business</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<BusinessesTable />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { ClientForm } from "~/components/client-form";
|
||||
import { PageHeader } from "~/components/page-header";
|
||||
|
||||
interface EditClientPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -10,14 +12,11 @@ export default async function EditClientPage({ params }: EditClientPageProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Edit Client
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Update client information below.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Edit Client"
|
||||
description="Update client information below."
|
||||
variant="gradient"
|
||||
/>
|
||||
<HydrateClient>
|
||||
<ClientForm mode="edit" clientId={id} />
|
||||
</HydrateClient>
|
||||
|
||||
@@ -3,6 +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 Link from "next/link";
|
||||
import {
|
||||
Edit,
|
||||
@@ -53,32 +54,27 @@ export default async function ClientDetailPage({
|
||||
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:mr-4 md:ml-72 md:p-6">
|
||||
<div>
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
{client.name}
|
||||
</h1>
|
||||
<p className="text-muted-foreground dark:text-gray-300">
|
||||
Client Details
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/clients/${client.id}/edit`}>
|
||||
<Button className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700">
|
||||
<PageHeader
|
||||
title={client.name}
|
||||
description="Client Details"
|
||||
variant="gradient"
|
||||
>
|
||||
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||
<Button variant="brand">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Client
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Client Information Card */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="border-0 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
|
||||
<CardTitle className="flex items-center space-x-2 text-green-600">
|
||||
<Building className="h-5 w-5" />
|
||||
<span>Contact Information</span>
|
||||
</CardTitle>
|
||||
@@ -92,10 +88,10 @@ export default async function ClientDetailPage({
|
||||
<Mail className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Email
|
||||
</p>
|
||||
<p className="text-sm dark:text-gray-300">
|
||||
<p className="text-foreground text-sm">
|
||||
{client.email}
|
||||
</p>
|
||||
</div>
|
||||
@@ -108,10 +104,10 @@ export default async function ClientDetailPage({
|
||||
<Phone className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Phone
|
||||
</p>
|
||||
<p className="text-sm dark:text-gray-300">
|
||||
<p className="text-foreground text-sm">
|
||||
{client.phone}
|
||||
</p>
|
||||
</div>
|
||||
@@ -127,12 +123,12 @@ export default async function ClientDetailPage({
|
||||
<MapPin className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Address
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-11 space-y-1 text-sm dark:text-gray-300">
|
||||
<div className="text-foreground ml-11 space-y-1 text-sm">
|
||||
{client.addressLine1 && <p>{client.addressLine1}</p>}
|
||||
{client.addressLine2 && <p>{client.addressLine2}</p>}
|
||||
{(client.city ?? client.state ?? client.postalCode) && (
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
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 { UserPlus, Pencil, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Type for client data
|
||||
interface Client {
|
||||
id: string;
|
||||
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;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
interface ClientsDataTableProps {
|
||||
clients: Client[];
|
||||
}
|
||||
|
||||
const formatAddress = (client: Client) => {
|
||||
const parts = [
|
||||
client.addressLine1,
|
||||
client.addressLine2,
|
||||
client.city,
|
||||
client.state,
|
||||
client.postalCode,
|
||||
].filter(Boolean);
|
||||
return parts.join(", ") || "—";
|
||||
};
|
||||
|
||||
export function ClientsDataTable({
|
||||
clients: initialClients,
|
||||
}: ClientsDataTableProps) {
|
||||
const [clients, setClients] = useState(initialClients);
|
||||
const [clientToDelete, setClientToDelete] = useState<Client | null>(null);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteClientMutation = api.clients.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Client deleted successfully");
|
||||
setClients(clients.filter((c) => c.id !== clientToDelete?.id));
|
||||
setClientToDelete(null);
|
||||
void utils.clients.getAll.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete client: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!clientToDelete) return;
|
||||
deleteClientMutation.mutate({ id: clientToDelete.id });
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Client>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const client = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-status-info-muted hidden rounded-lg p-2 sm:flex">
|
||||
<UserPlus className="text-status-info h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{client.name}</p>
|
||||
<p className="text-muted-foreground truncate text-sm">
|
||||
{client.email || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "phone",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Phone" />
|
||||
),
|
||||
cell: ({ row }) => row.original.phone || "—",
|
||||
meta: {
|
||||
headerClassName: "hidden md:table-cell",
|
||||
cellClassName: "hidden md:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "address",
|
||||
header: "Address",
|
||||
cell: ({ row }) => formatAddress(row.original),
|
||||
meta: {
|
||||
headerClassName: "hidden lg:table-cell",
|
||||
cellClassName: "hidden lg:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt") as Date;
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(new Date(date));
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden xl:table-cell",
|
||||
cellClassName: "hidden xl:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const client = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setClientToDelete(client)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={clients}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search clients..."
|
||||
/>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog
|
||||
open={!!clientToDelete}
|
||||
onOpenChange={(open) => !open && setClientToDelete(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
client "{clientToDelete?.name}" and remove all associated data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setClientToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteClientMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { UniversalTable } from "~/components/ui/universal-table";
|
||||
import { TableSkeleton } from "~/components/ui/skeleton";
|
||||
import { DataTableSkeleton } from "~/components/ui/data-table";
|
||||
import { ClientsDataTable } from "./clients-data-table";
|
||||
|
||||
export function ClientsTable() {
|
||||
const { isLoading } = api.clients.getAll.useQuery();
|
||||
const { data: clients, isLoading } = api.clients.getAll.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton rows={8} />;
|
||||
return <DataTableSkeleton columns={5} rows={8} />;
|
||||
}
|
||||
|
||||
return <UniversalTable resource="clients" />;
|
||||
}
|
||||
if (!clients) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ClientsDataTable clients={clients} />;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { ClientForm } from "~/components/client-form";
|
||||
import { PageHeader } from "~/components/page-header";
|
||||
|
||||
export default async function NewClientPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Add Client
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Enter client details below to add a new client.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Add Client"
|
||||
description="Enter client details below to add a new client."
|
||||
variant="gradient"
|
||||
/>
|
||||
<HydrateClient>
|
||||
<ClientForm mode="create" />
|
||||
</HydrateClient>
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
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";
|
||||
|
||||
export default async function ClientsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Clients
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Manage your clients and their information.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
||||
>
|
||||
<>
|
||||
<PageHeader
|
||||
title="Clients"
|
||||
description="Manage your clients and their information."
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild variant="brand">
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Plus className="mr-2 h-5 w-5" /> Add Client
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>Add Client</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<ClientsTable />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
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";
|
||||
}
|
||||
|
||||
export function PDFDownloadButton({
|
||||
invoice,
|
||||
variant = "button",
|
||||
}: PDFDownloadButtonProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(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,
|
||||
};
|
||||
|
||||
await generateInvoicePDF(pdfData);
|
||||
|
||||
toast.success("PDF downloaded successfully");
|
||||
} catch (error) {
|
||||
console.error("PDF generation error:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to generate PDF",
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
onClick={handleDownloadPDF}
|
||||
disabled={isGenerating}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleDownloadPDF}
|
||||
disabled={isGenerating}
|
||||
className="w-full justify-start"
|
||||
variant="outline"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isGenerating ? "Generating..." : "Download PDF"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
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 { DatePicker } from "~/components/ui/date-picker";
|
||||
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Plus,
|
||||
Trash2,
|
||||
FileText,
|
||||
Building,
|
||||
User,
|
||||
Loader2,
|
||||
Send,
|
||||
DollarSign,
|
||||
Hash,
|
||||
Edit3,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
|
||||
interface EditInvoicePageProps {}
|
||||
|
||||
interface InvoiceItem {
|
||||
id?: string;
|
||||
tempId: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface InvoiceFormData {
|
||||
invoiceNumber: string;
|
||||
businessId: string | undefined;
|
||||
clientId: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
notes: string;
|
||||
taxRate: number;
|
||||
items: InvoiceItem[];
|
||||
status: "draft" | "sent" | "paid" | "overdue";
|
||||
}
|
||||
|
||||
function InvoiceItemCard({
|
||||
item,
|
||||
index,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
_isLast,
|
||||
}: {
|
||||
item: InvoiceItem;
|
||||
index: number;
|
||||
onUpdate: (
|
||||
index: number,
|
||||
field: keyof InvoiceItem,
|
||||
value: string | number | Date,
|
||||
) => void;
|
||||
onDelete: (index: number) => void;
|
||||
_isLast: boolean;
|
||||
}) {
|
||||
const handleFieldChange = (
|
||||
field: keyof InvoiceItem,
|
||||
value: string | number | Date,
|
||||
) => {
|
||||
onUpdate(index, field, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-border/50 border p-3 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
{/* Header with item number and delete */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Item {index + 1}
|
||||
</span>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Item</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this line item? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDelete(index)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<Textarea
|
||||
value={item.description}
|
||||
onChange={(e) => handleFieldChange("description", e.target.value)}
|
||||
placeholder="Description of work..."
|
||||
className="min-h-[48px] resize-none text-sm"
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* Date, Hours, Rate, Amount in compact grid */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Date</Label>
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) =>
|
||||
handleFieldChange("date", date ?? new Date())
|
||||
}
|
||||
className="[&>button]:h-8 [&>button]:text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Hours</Label>
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => handleFieldChange("hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
placeholder="0"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Rate</Label>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => handleFieldChange("rate", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
placeholder="0.00"
|
||||
prefix="$"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Amount</Label>
|
||||
<div className="bg-muted/30 flex h-8 items-center rounded-md border px-2">
|
||||
<span className="font-mono text-xs font-medium text-emerald-600">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceEditor({ invoiceId }: { invoiceId: string }) {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [formData, setFormData] = useState<InvoiceFormData | null>(null);
|
||||
|
||||
// Floating action bar ref
|
||||
const footerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Queries
|
||||
const { data: invoice, isLoading: invoiceLoading } =
|
||||
api.invoices.getById.useQuery({
|
||||
id: invoiceId,
|
||||
});
|
||||
const { data: clients, isLoading: clientsLoading } =
|
||||
api.clients.getAll.useQuery();
|
||||
const { data: businesses, isLoading: businessesLoading } =
|
||||
api.businesses.getAll.useQuery();
|
||||
|
||||
// Mutations
|
||||
const updateInvoice = api.invoices.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Invoice updated successfully");
|
||||
router.push(`/dashboard/invoices/${invoiceId}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to update invoice");
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize form data when invoice loads
|
||||
useEffect(() => {
|
||||
if (invoice) {
|
||||
const transformedItems: InvoiceItem[] =
|
||||
invoice.items?.map((item, index) => ({
|
||||
id: item.id,
|
||||
tempId: item.id || `temp-${index}`,
|
||||
date: item.date || new Date(),
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
})) || [];
|
||||
|
||||
setFormData({
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
businessId: invoice.businessId ?? undefined,
|
||||
clientId: invoice.clientId,
|
||||
issueDate: new Date(invoice.issueDate),
|
||||
dueDate: new Date(invoice.dueDate),
|
||||
notes: invoice.notes ?? "",
|
||||
taxRate: invoice.taxRate,
|
||||
items: transformedItems ?? [],
|
||||
status: invoice.status as "draft" | "sent" | "paid" | "overdue",
|
||||
});
|
||||
}
|
||||
}, [invoice]);
|
||||
|
||||
const handleItemUpdate = (
|
||||
index: number,
|
||||
field: keyof InvoiceItem,
|
||||
value: string | number | Date,
|
||||
) => {
|
||||
if (!formData) return;
|
||||
|
||||
const updatedItems = [...formData.items];
|
||||
const currentItem = updatedItems[index];
|
||||
if (currentItem) {
|
||||
updatedItems[index] = { ...currentItem, [field]: value };
|
||||
|
||||
// Recalculate amount for hours or rate changes
|
||||
if (field === "hours" || field === "rate") {
|
||||
const updatedItem = updatedItems[index];
|
||||
if (!updatedItem) return;
|
||||
updatedItem.amount = updatedItem.hours * updatedItem.rate;
|
||||
}
|
||||
}
|
||||
|
||||
setFormData({ ...formData, items: updatedItems });
|
||||
};
|
||||
|
||||
const handleItemDelete = (index: number) => {
|
||||
if (!formData) return;
|
||||
|
||||
if (formData.items.length === 1) {
|
||||
toast.error("At least one line item is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedItems = formData.items.filter((_, i) => i !== index);
|
||||
setFormData({ ...formData, items: updatedItems });
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
if (!formData) return;
|
||||
|
||||
const newItem: InvoiceItem = {
|
||||
tempId: `item-${Date.now()}`,
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: 0,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
};
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
items: [...formData.items, newItem],
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
await handleSave("draft");
|
||||
};
|
||||
|
||||
const handleUpdateInvoice = async () => {
|
||||
await handleSave(formData?.status ?? "draft");
|
||||
};
|
||||
|
||||
const handleSave = async (status: "draft" | "sent" | "paid" | "overdue") => {
|
||||
if (!formData) return;
|
||||
|
||||
// Validation
|
||||
if (!formData.clientId) {
|
||||
toast.error("Please select a client");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.items.length === 0) {
|
||||
toast.error("At least one line item is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if all items have required fields
|
||||
const invalidItems = formData.items.some(
|
||||
(item) => !item.description.trim() || item.hours <= 0 || item.rate <= 0,
|
||||
);
|
||||
|
||||
if (invalidItems) {
|
||||
toast.error("All line items must have description, hours, and rate");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await updateInvoice.mutateAsync({
|
||||
id: invoiceId,
|
||||
...formData,
|
||||
businessId: formData.businessId ?? undefined,
|
||||
status,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateSubtotal = () => {
|
||||
if (!formData) return 0;
|
||||
return formData.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
};
|
||||
|
||||
const calculateTax = () => {
|
||||
if (!formData) return 0;
|
||||
return (calculateSubtotal() * formData.taxRate) / 100;
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
return calculateSubtotal() + calculateTax();
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
if (!formData) return false;
|
||||
return (
|
||||
formData.clientId &&
|
||||
formData.items.length > 0 &&
|
||||
formData.items.every(
|
||||
(item) => item.description.trim() && item.hours > 0 && item.rate > 0,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return <Badge variant="secondary">Draft</Badge>;
|
||||
case "sent":
|
||||
return <Badge variant="default">Sent</Badge>;
|
||||
case "paid":
|
||||
return (
|
||||
<Badge variant="outline" className="border-green-500 text-green-700">
|
||||
Paid
|
||||
</Badge>
|
||||
);
|
||||
case "overdue":
|
||||
return <Badge variant="destructive">Overdue</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
if (invoiceLoading || clientsLoading || businessesLoading || !formData) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Edit Invoice"
|
||||
description="Loading invoice data..."
|
||||
variant="gradient"
|
||||
/>
|
||||
<Card className="shadow-xl">
|
||||
<CardContent className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={`Edit Invoice`}
|
||||
description="Update invoice details and line items"
|
||||
variant="gradient"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(formData.status)}
|
||||
<Link href={`/dashboard/invoices/${invoiceId}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
<span className="hidden sm:inline">View Invoice</span>
|
||||
<span className="sm:hidden">View</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Invoice Header */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-emerald-600" />
|
||||
Invoice Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Invoice Number</Label>
|
||||
<div className="bg-muted/30 flex h-10 items-center rounded-md border px-3">
|
||||
<Hash className="text-muted-foreground mr-2 h-4 w-4" />
|
||||
<span className="font-mono text-sm font-medium">
|
||||
{formData.invoiceNumber}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DatePicker
|
||||
date={formData.issueDate}
|
||||
onDateChange={(date) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
issueDate: date ?? new Date(),
|
||||
})
|
||||
}
|
||||
label="Issue Date"
|
||||
required
|
||||
/>
|
||||
|
||||
<DatePicker
|
||||
date={formData.dueDate}
|
||||
onDateChange={(date) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
dueDate: date ?? new Date(),
|
||||
})
|
||||
}
|
||||
label="Due Date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business & Client */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5 text-emerald-600" />
|
||||
Business & Client
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">From Business</Label>
|
||||
<div className="relative">
|
||||
<Building className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Select
|
||||
value={formData.businessId ?? ""}
|
||||
onValueChange={(value) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
businessId: value || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="pl-9">
|
||||
<SelectValue placeholder="Select business..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{businesses?.map((business) => (
|
||||
<SelectItem key={business.id} value={business.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{business.name}</span>
|
||||
{business.isDefault && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Client *</Label>
|
||||
<div className="relative">
|
||||
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Select
|
||||
value={formData.clientId}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, clientId: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="pl-9">
|
||||
<SelectValue placeholder="Select client..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients?.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="font-medium">{client.name}</span>
|
||||
<span className="text-muted-foreground ml-2 text-sm">
|
||||
{client.email}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Line Items */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Edit3 className="h-5 w-5 text-emerald-600" />
|
||||
Line Items ({formData.items.length})
|
||||
</CardTitle>
|
||||
<Button
|
||||
onClick={handleAddItem}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Add Item</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{formData.items.map((item, index) => (
|
||||
<InvoiceItemCard
|
||||
key={item.tempId}
|
||||
item={item}
|
||||
index={index}
|
||||
onUpdate={handleItemUpdate}
|
||||
onDelete={handleItemDelete}
|
||||
_isLast={index === formData.items.length - 1}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes & Totals */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||
{/* Notes */}
|
||||
<Card className="shadow-lg lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-emerald-600" />
|
||||
Notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, notes: e.target.value })
|
||||
}
|
||||
placeholder="Payment terms, additional notes..."
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tax & Totals */}
|
||||
<Card className="shadow-lg lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5 text-emerald-600" />
|
||||
Tax & Totals
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Tax Rate (%)</Label>
|
||||
<NumberInput
|
||||
value={formData.taxRate}
|
||||
onChange={(value) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
taxRate: value,
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
suffix="%"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/20 rounded-lg border p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Subtotal:</span>
|
||||
<span className="font-mono font-medium">
|
||||
${calculateSubtotal().toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Tax ({formData.taxRate}%):
|
||||
</span>
|
||||
<span className="font-mono font-medium">
|
||||
${calculateTax().toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-base font-bold">
|
||||
<span>Total:</span>
|
||||
<span className="font-mono text-emerald-600">
|
||||
${calculateTotal().toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Form Actions - original position */}
|
||||
<div
|
||||
ref={footerRef}
|
||||
className="border-border/40 bg-background/60 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Editing invoice {formData.invoiceNumber}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/dashboard/invoices/${invoiceId}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
className="border-border/40 hover:bg-accent/50"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={isLoading || !isFormValid()}
|
||||
variant="outline"
|
||||
className="border-border/40 hover:bg-accent/50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdateInvoice}
|
||||
disabled={isLoading || !isFormValid()}
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Update Invoice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingActionBar
|
||||
triggerRef={footerRef}
|
||||
title={`Editing invoice ${formData.invoiceNumber}`}
|
||||
>
|
||||
<Link href={`/dashboard/invoices/${invoiceId}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
className="border-border/40 hover:bg-accent/50"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={isLoading || !isFormValid()}
|
||||
variant="outline"
|
||||
className="border-border/40 hover:bg-accent/50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdateInvoice}
|
||||
disabled={isLoading || !isFormValid()}
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Update Invoice
|
||||
</Button>
|
||||
</FloatingActionBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditInvoicePage() {
|
||||
const params = useParams();
|
||||
const invoiceId = Array.isArray(params?.id) ? params.id[0] : params?.id;
|
||||
|
||||
if (!invoiceId) return null;
|
||||
|
||||
return <InvoiceEditor invoiceId={invoiceId} />;
|
||||
}
|
||||
@@ -1,72 +1,534 @@
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { InvoiceView } from "~/components/invoice-view";
|
||||
import { InvoiceForm } from "~/components/invoice-form";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Edit, Eye, ArrowLeft } from "lucide-react";
|
||||
import { UnifiedInvoicePage } from "./_components/unified-invoice-page";
|
||||
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 { Separator } from "~/components/ui/separator";
|
||||
import { PageHeader } from "~/components/page-header";
|
||||
import { PDFDownloadButton } from "./_components/pdf-download-button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Send,
|
||||
Copy,
|
||||
MoreHorizontal,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Calendar,
|
||||
FileText,
|
||||
Building,
|
||||
User,
|
||||
DollarSign,
|
||||
Hash,
|
||||
MapPin,
|
||||
Mail,
|
||||
Phone,
|
||||
} from "lucide-react";
|
||||
|
||||
interface InvoicePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ mode?: string }>;
|
||||
}
|
||||
|
||||
export default async function InvoicePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: InvoicePageProps) {
|
||||
const { id } = await params;
|
||||
const { mode = "view" } = await searchParams;
|
||||
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 (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Invoice Details
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600 dark:text-gray-300">
|
||||
View and manage invoice information.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={actualStatus} className="flex items-center gap-1">
|
||||
<Icon className="h-3 w-3" />
|
||||
{actualStatus.charAt(0).toUpperCase() + actualStatus.slice(1)}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="relative flex rounded-lg border border-gray-200 bg-gray-100 p-1 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div
|
||||
className={`absolute top-1 bottom-1 rounded-md bg-white shadow-sm transition-all duration-300 ease-in-out dark:bg-gray-700 ${
|
||||
mode === "view" ? "left-1 w-10" : "left-11 w-10"
|
||||
}`}
|
||||
/>
|
||||
<Link
|
||||
href={`/dashboard/invoices/${id}?mode=view`}
|
||||
className={`relative z-10 rounded-md px-3 py-2 transition-all duration-200 ${
|
||||
mode === "view"
|
||||
? "text-emerald-600"
|
||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/invoices/${id}?mode=edit`}
|
||||
className={`relative z-10 rounded-md px-3 py-2 transition-all duration-200 ${
|
||||
mode === "edit"
|
||||
? "text-emerald-600"
|
||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
async function InvoiceDetails({ invoiceId }: { invoiceId: string }) {
|
||||
const invoice = await api.invoices.getById({ id: invoiceId });
|
||||
|
||||
<div className="mt-4">
|
||||
<HydrateClient>
|
||||
<UnifiedInvoicePage invoiceId={id} mode={mode} />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
if (!invoice) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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.status === "paid" || invoice.status === "overdue") && (
|
||||
<div className="text-center">
|
||||
<InvoiceStatusBadge
|
||||
status={invoice.status}
|
||||
dueDate={invoice.dueDate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<div>Loading invoice details...</div>}>
|
||||
<InvoiceDetails invoiceId={id} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
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 { 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 { Plus, FileText, Eye, Edit } from "lucide-react";
|
||||
|
||||
// Type for invoice data
|
||||
interface Invoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
clientId: string;
|
||||
businessId: string | null;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
taxRate: number;
|
||||
notes: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
client?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
} | null;
|
||||
business?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
} | null;
|
||||
items?: Array<{
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
interface InvoicesDataTableProps {
|
||||
invoices: Invoice[];
|
||||
}
|
||||
|
||||
const getStatusType = (invoice: Invoice): StatusType => {
|
||||
if (invoice.status === "paid") return "paid";
|
||||
if (invoice.status === "draft") return "draft";
|
||||
if (invoice.status === "sent") {
|
||||
const dueDate = new Date(invoice.dueDate);
|
||||
return dueDate < new Date() ? "overdue" : "sent";
|
||||
}
|
||||
return "draft";
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = [
|
||||
{
|
||||
accessorKey: "invoiceNumber",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Invoice" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-status-success-muted hidden rounded-lg p-2 sm:flex">
|
||||
<FileText className="text-status-success h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{invoice.invoiceNumber}</p>
|
||||
<p className="text-muted-foreground truncate text-sm">
|
||||
{invoice.items?.length || 0} items
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "client.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Client" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{invoice.client?.name || "—"}</p>
|
||||
<p className="text-muted-foreground truncate text-sm">
|
||||
{invoice.client?.email || "—"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "issueDate",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Issue Date" />
|
||||
),
|
||||
cell: ({ row }) => formatDate(row.getValue("issueDate")),
|
||||
meta: {
|
||||
headerClassName: "hidden md:table-cell",
|
||||
cellClassName: "hidden md:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "dueDate",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Due Date" />
|
||||
),
|
||||
cell: ({ row }) => formatDate(row.getValue("dueDate")),
|
||||
meta: {
|
||||
headerClassName: "hidden lg:table-cell",
|
||||
cellClassName: "hidden lg:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "totalAmount",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Amount" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const amount = row.getValue("totalAmount") as number;
|
||||
return <p className="font-semibold">{formatCurrency(amount)}</p>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return <StatusBadge status={getStatusType(invoice)} />;
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
const invoice = row.original;
|
||||
const status = getStatusType(invoice);
|
||||
return value.includes(status);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
const filterableColumns = [
|
||||
{
|
||||
id: "status",
|
||||
title: "Status",
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Sent", value: "sent" },
|
||||
{ label: "Paid", value: "paid" },
|
||||
{ label: "Overdue", value: "overdue" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={invoices}
|
||||
searchKey="invoiceNumber"
|
||||
searchPlaceholder="Search invoices..."
|
||||
filterableColumns={filterableColumns}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { UniversalTable } from "~/components/ui/universal-table";
|
||||
import { TableSkeleton } from "~/components/ui/skeleton";
|
||||
|
||||
export function InvoicesTable() {
|
||||
const { isLoading } = api.invoices.getAll.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton rows={8} />;
|
||||
}
|
||||
|
||||
return <UniversalTable resource="invoices" />;
|
||||
}
|
||||
@@ -1,20 +1,471 @@
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { CSVImportPage } from "~/components/csv-import-page";
|
||||
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 {
|
||||
ArrowLeft,
|
||||
Upload,
|
||||
FileText,
|
||||
Download,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Zap,
|
||||
FileSpreadsheet,
|
||||
Eye,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
// Import Statistics Component
|
||||
function ImportStats() {
|
||||
const stats = [
|
||||
{
|
||||
title: "Supported Formats",
|
||||
value: "CSV",
|
||||
icon: FileSpreadsheet,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-50 dark:bg-blue-900/20",
|
||||
description: "Excel & Google Sheets exports",
|
||||
},
|
||||
{
|
||||
title: "Max File Size",
|
||||
value: "10MB",
|
||||
icon: Upload,
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-50 dark:bg-green-900/20",
|
||||
description: "Up to 1000 invoices",
|
||||
},
|
||||
{
|
||||
title: "Processing Time",
|
||||
value: "< 1min",
|
||||
icon: Zap,
|
||||
color: "text-purple-600",
|
||||
bgColor: "bg-purple-50 dark:bg-purple-900/20",
|
||||
description: "Average processing speed",
|
||||
},
|
||||
{
|
||||
title: "Success Rate",
|
||||
value: "99.9%",
|
||||
icon: CheckCircle,
|
||||
color: "text-emerald-600",
|
||||
bgColor: "bg-emerald-50 dark:bg-emerald-900/20",
|
||||
description: "Import success rate",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Card
|
||||
key={stat.title}
|
||||
className="border-0 shadow-md transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
{stat.title}
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{stat.value}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{stat.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`rounded-full p-3 ${stat.bgColor}`}>
|
||||
<Icon className={`h-6 w-6 ${stat.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// File Upload Component
|
||||
function FileUploadArea() {
|
||||
return (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Upload className="h-5 w-5 text-emerald-600" />
|
||||
Upload CSV File
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-8">
|
||||
<div className="mx-auto max-w-xl">
|
||||
{/* Drop Zone */}
|
||||
<div className="rounded-lg border-2 border-dashed border-emerald-300 bg-emerald-50/50 p-12 text-center transition-colors hover:border-emerald-400 hover:bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-900/10 dark:hover:bg-emerald-900/20">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<Upload className="h-8 w-8 text-emerald-600" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Drop your CSV file here
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
or click to browse and select a file
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700"
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Choose File
|
||||
</Button>
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
Maximum file size: 10MB • Supported format: CSV
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Progress (hidden by default) */}
|
||||
<div className="mt-6 hidden">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Uploading...</span>
|
||||
<span className="text-sm text-emerald-600">75%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-600 to-teal-600 transition-all duration-300"
|
||||
style={{ width: "75%" }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// CSV Format Instructions
|
||||
function FormatInstructions() {
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Required Format */}
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
Required CSV Format
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg bg-gray-50 p-4 dark:bg-gray-800/50">
|
||||
<p className="font-mono text-sm text-gray-700 dark:text-gray-300">
|
||||
client_name,client_email,invoice_number,issue_date,due_date,description,hours,rate,tax_rate
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold">Required Columns:</h4>
|
||||
<div className="grid gap-2">
|
||||
{[
|
||||
{ field: "client_name", desc: "Full name of the client" },
|
||||
{ field: "client_email", desc: "Client email address" },
|
||||
{ field: "invoice_number", desc: "Unique invoice identifier" },
|
||||
{ field: "issue_date", desc: "Date issued (YYYY-MM-DD)" },
|
||||
{ field: "due_date", desc: "Payment due date (YYYY-MM-DD)" },
|
||||
{ field: "description", desc: "Work description" },
|
||||
{ field: "hours", desc: "Number of hours worked" },
|
||||
{ field: "rate", desc: "Hourly rate (decimal)" },
|
||||
].map((col) => (
|
||||
<div key={col.field} className="flex items-start gap-3">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{col.field}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{col.desc}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<h4 className="mb-2 font-semibold">Optional Columns:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
tax_rate
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
notes
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
client_phone
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sample Data & Download */}
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Download className="h-5 w-5 text-green-600" />
|
||||
Sample Template
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Download our sample CSV template to see the exact format required
|
||||
for importing invoices.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg bg-green-50 p-4 dark:bg-green-900/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="mt-0.5 h-5 w-5 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-400">
|
||||
Pro Tip
|
||||
</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
The template includes sample data and formatting examples to
|
||||
help you get started quickly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download Sample CSV Template
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Template in Browser
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Sample Row:</h4>
|
||||
<div className="rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50">
|
||||
<p className="font-mono text-xs break-all text-gray-600 dark:text-gray-400">
|
||||
"Acme
|
||||
Corp","john@acme.com","INV-001","2024-01-15","2024-02-14","Web
|
||||
development work","40","75.00","8.5"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Important Notes Section
|
||||
function ImportantNotes() {
|
||||
return (
|
||||
<Card className="border-0 border-l-4 border-l-amber-500 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600" />
|
||||
Important Notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="mb-2 font-semibold">Before Importing:</h4>
|
||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||
<li>• Ensure all client emails are valid</li>
|
||||
<li>• Use YYYY-MM-DD format for dates</li>
|
||||
<li>• Invoice numbers must be unique</li>
|
||||
<li>• Rates should be in decimal format (e.g., 75.50)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 font-semibold">What Happens:</h4>
|
||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||
<li>• New clients will be created automatically</li>
|
||||
<li>• Existing clients will be matched by email</li>
|
||||
<li>• Invoices will be created in "draft" status</li>
|
||||
<li>• You can review and edit before sending</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Import History Component
|
||||
function ImportHistory() {
|
||||
const mockHistory = [
|
||||
{
|
||||
id: "1",
|
||||
filename: "january_invoices.csv",
|
||||
date: "2024-01-15",
|
||||
status: "completed",
|
||||
imported: 25,
|
||||
errors: 0,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
filename: "december_invoices.csv",
|
||||
date: "2024-01-01",
|
||||
status: "completed",
|
||||
imported: 18,
|
||||
errors: 2,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
filename: "november_invoices.csv",
|
||||
date: "2023-12-01",
|
||||
status: "completed",
|
||||
imported: 32,
|
||||
errors: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
if (status === "completed") {
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
Completed
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === "processing") {
|
||||
return (
|
||||
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Processing
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline">
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
Failed
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="h-5 w-5 text-purple-600" />
|
||||
Recent Imports
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr className="border-b">
|
||||
<th className="p-4 text-left text-sm font-medium">File</th>
|
||||
<th className="p-4 text-left text-sm font-medium">Date</th>
|
||||
<th className="p-4 text-left text-sm font-medium">Status</th>
|
||||
<th className="p-4 text-right text-sm font-medium">Imported</th>
|
||||
<th className="p-4 text-right text-sm font-medium">Errors</th>
|
||||
<th className="p-4 text-center text-sm font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockHistory.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-muted/20 border-b transition-colors"
|
||||
>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
||||
<FileSpreadsheet className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
<span className="font-medium">{item.filename}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-sm">
|
||||
{new Date(item.date).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="p-4">{getStatusBadge(item.status)}</td>
|
||||
<td className="p-4 text-right font-medium">
|
||||
{item.imported}
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
{item.errors > 0 ? (
|
||||
<span className="text-red-600">{item.errors}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{mockHistory.length === 0 && (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">No import history yet</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ImportPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Import Invoices
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Upload CSV files to create invoices in batch.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Import Invoices"
|
||||
description="Upload CSV files to create invoices in batch"
|
||||
variant="gradient"
|
||||
>
|
||||
<Link href="/dashboard/invoices">
|
||||
<Button variant="outline" size="lg">
|
||||
<ArrowLeft className="mr-2 h-5 w-5" />
|
||||
Back to Invoices
|
||||
</Button>
|
||||
</Link>
|
||||
</PageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<CSVImportPage />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i} className="border-0 shadow-md">
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-muted mb-2 h-4 w-1/2 rounded"></div>
|
||||
<div className="bg-muted mb-2 h-8 w-3/4 rounded"></div>
|
||||
<div className="bg-muted h-3 w-1/3 rounded"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ImportStats />
|
||||
</Suspense>
|
||||
|
||||
<FileUploadArea />
|
||||
|
||||
<FormatInstructions />
|
||||
|
||||
<ImportantNotes />
|
||||
|
||||
<ImportHistory />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,740 @@
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { InvoiceForm } from "~/components/invoice-form";
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
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 { DatePicker } from "~/components/ui/date-picker";
|
||||
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Plus,
|
||||
Trash2,
|
||||
FileText,
|
||||
Building,
|
||||
User,
|
||||
Loader2,
|
||||
Send,
|
||||
DollarSign,
|
||||
Hash,
|
||||
Edit3,
|
||||
} from "lucide-react";
|
||||
|
||||
interface InvoiceItem {
|
||||
tempId: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface InvoiceFormData {
|
||||
invoiceNumber: string;
|
||||
businessId: string | undefined;
|
||||
clientId: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
notes: string;
|
||||
taxRate: number;
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
function InvoiceItemCard({
|
||||
item,
|
||||
index,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
_isLast,
|
||||
}: {
|
||||
item: InvoiceItem;
|
||||
index: number;
|
||||
onUpdate: (
|
||||
index: number,
|
||||
field: keyof InvoiceItem,
|
||||
value: string | number | Date,
|
||||
) => void;
|
||||
onDelete: (index: number) => void;
|
||||
_isLast: boolean;
|
||||
}) {
|
||||
const handleFieldChange = (
|
||||
field: keyof InvoiceItem,
|
||||
value: string | number | Date,
|
||||
) => {
|
||||
onUpdate(index, field, value);
|
||||
};
|
||||
|
||||
export default async function NewInvoicePage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Create Invoice
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Fill out the details below to create a new invoice.
|
||||
</p>
|
||||
<Card className="border-border/50 border p-3 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
{/* Header with item number and delete */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Item {index + 1}
|
||||
</span>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Item</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this line item? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDelete(index)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<Textarea
|
||||
value={item.description}
|
||||
onChange={(e) => handleFieldChange("description", e.target.value)}
|
||||
placeholder="Description of work..."
|
||||
className="min-h-[48px] resize-none text-sm"
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* Date, Hours, Rate, Amount in compact grid */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Date</Label>
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) =>
|
||||
handleFieldChange("date", date ?? new Date())
|
||||
}
|
||||
className="[&>button]:h-8 [&>button]:text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Hours</Label>
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => handleFieldChange("hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
placeholder="0"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Rate</Label>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => handleFieldChange("rate", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
placeholder="0.00"
|
||||
prefix="$"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Amount</Label>
|
||||
<div className="bg-muted/30 flex h-8 items-center rounded-md border px-2">
|
||||
<span className="font-mono text-xs font-medium text-emerald-600">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HydrateClient>
|
||||
<InvoiceForm />
|
||||
</HydrateClient>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NewInvoicePage() {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Initialize form data with defaults
|
||||
const today = new Date();
|
||||
const thirtyDaysFromNow = new Date(today);
|
||||
thirtyDaysFromNow.setDate(today.getDate() + 30);
|
||||
|
||||
// Auto-generate invoice number
|
||||
const generateInvoiceNumber = () => {
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const timestamp = Date.now().toString().slice(-4);
|
||||
return `INV-${year}${month}-${timestamp}`;
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState<InvoiceFormData>({
|
||||
invoiceNumber: generateInvoiceNumber(),
|
||||
businessId: undefined,
|
||||
clientId: "",
|
||||
issueDate: today,
|
||||
dueDate: thirtyDaysFromNow,
|
||||
notes: "",
|
||||
taxRate: 0,
|
||||
items: [
|
||||
{
|
||||
tempId: `item-${Date.now()}`,
|
||||
date: today,
|
||||
description: "",
|
||||
hours: 0,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Floating action bar ref
|
||||
const footerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Queries
|
||||
const { data: clients, isLoading: clientsLoading } =
|
||||
api.clients.getAll.useQuery();
|
||||
const { data: businesses, isLoading: businessesLoading } =
|
||||
api.businesses.getAll.useQuery();
|
||||
|
||||
// Set default business when data loads
|
||||
useEffect(() => {
|
||||
if (businesses && !formData.businessId) {
|
||||
const defaultBusiness = businesses.find((b) => b.isDefault);
|
||||
if (defaultBusiness) {
|
||||
setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id }));
|
||||
}
|
||||
}
|
||||
}, [businesses, formData.businessId]);
|
||||
|
||||
// Mutations
|
||||
const createInvoice = api.invoices.create.useMutation({
|
||||
onSuccess: (invoice) => {
|
||||
toast.success("Invoice created successfully");
|
||||
router.push(`/dashboard/invoices/${invoice.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to create invoice");
|
||||
},
|
||||
});
|
||||
|
||||
const handleItemUpdate = (
|
||||
index: number,
|
||||
field: keyof InvoiceItem,
|
||||
value: string | number | Date,
|
||||
) => {
|
||||
const updatedItems = [...formData.items];
|
||||
const currentItem = updatedItems[index];
|
||||
if (currentItem) {
|
||||
updatedItems[index] = { ...currentItem, [field]: value };
|
||||
|
||||
// Recalculate amount for hours or rate changes
|
||||
if (field === "hours" || field === "rate") {
|
||||
const updatedItem = updatedItems[index];
|
||||
if (!updatedItem) return;
|
||||
updatedItem.amount = updatedItem.hours * updatedItem.rate;
|
||||
}
|
||||
}
|
||||
|
||||
setFormData({ ...formData, items: updatedItems });
|
||||
};
|
||||
|
||||
const handleItemDelete = (index: number) => {
|
||||
if (formData.items.length === 1) {
|
||||
toast.error("At least one line item is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedItems = formData.items.filter((_, i) => i !== index);
|
||||
setFormData({ ...formData, items: updatedItems });
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
const newItem: InvoiceItem = {
|
||||
tempId: `item-${Date.now()}`,
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: 0,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
};
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
items: [...formData.items, newItem],
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
await handleSave("draft");
|
||||
};
|
||||
|
||||
const handleCreateInvoice = async () => {
|
||||
await handleSave("sent");
|
||||
};
|
||||
|
||||
const handleSave = async (status: "draft" | "sent") => {
|
||||
// Validation
|
||||
if (!formData.clientId) {
|
||||
toast.error("Please select a client");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.items.length === 0) {
|
||||
toast.error("At least one line item is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if all items have required fields
|
||||
const invalidItems = formData.items.some(
|
||||
(item) => !item.description.trim() || item.hours <= 0 || item.rate <= 0,
|
||||
);
|
||||
|
||||
if (invalidItems) {
|
||||
toast.error("All line items must have description, hours, and rate");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await createInvoice.mutateAsync({
|
||||
...formData,
|
||||
businessId: formData.businessId ?? undefined,
|
||||
status,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateSubtotal = () => {
|
||||
return formData.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
};
|
||||
|
||||
const calculateTax = () => {
|
||||
return (calculateSubtotal() * formData.taxRate) / 100;
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
return calculateSubtotal() + calculateTax();
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
return (
|
||||
formData.clientId &&
|
||||
formData.items.length > 0 &&
|
||||
formData.items.every(
|
||||
(item) => item.description.trim() && item.hours > 0 && item.rate > 0,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
if (clientsLoading || businessesLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Create Invoice"
|
||||
description="Loading form data..."
|
||||
variant="gradient"
|
||||
/>
|
||||
<Card className="shadow-xl">
|
||||
<CardContent className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Create Invoice"
|
||||
description="Fill out the details below to create a new invoice"
|
||||
variant="gradient"
|
||||
>
|
||||
<Link href="/dashboard/invoices">
|
||||
<Button variant="outline" size="sm" className="w-full sm:w-auto">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Back to Invoices</span>
|
||||
<span className="sm:hidden">Back</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Invoice Header */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-emerald-600" />
|
||||
Invoice Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Invoice Number</Label>
|
||||
<div className="bg-muted/30 flex h-10 items-center rounded-md border px-3">
|
||||
<Hash className="text-muted-foreground mr-2 h-4 w-4" />
|
||||
<span className="font-mono text-sm font-medium">
|
||||
{formData.invoiceNumber}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DatePicker
|
||||
date={formData.issueDate}
|
||||
onDateChange={(date) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
issueDate: date ?? new Date(),
|
||||
})
|
||||
}
|
||||
label="Issue Date"
|
||||
required
|
||||
/>
|
||||
|
||||
<DatePicker
|
||||
date={formData.dueDate}
|
||||
onDateChange={(date) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
dueDate: date ?? new Date(),
|
||||
})
|
||||
}
|
||||
label="Due Date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business & Client */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5 text-emerald-600" />
|
||||
Business & Client
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">From Business</Label>
|
||||
<div className="relative">
|
||||
<Building className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Select
|
||||
value={formData.businessId ?? ""}
|
||||
onValueChange={(value) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
businessId: value || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="pl-9">
|
||||
<SelectValue placeholder="Select business..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{businesses?.map((business) => (
|
||||
<SelectItem key={business.id} value={business.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{business.name}</span>
|
||||
{business.isDefault && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{(!businesses || businesses.length === 0) && (
|
||||
<p className="text-sm text-red-600">
|
||||
No businesses found.{" "}
|
||||
<Link
|
||||
href="/dashboard/businesses/new"
|
||||
className="underline hover:text-red-700"
|
||||
>
|
||||
Create one first
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Client *</Label>
|
||||
<div className="relative">
|
||||
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Select
|
||||
value={formData.clientId}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, clientId: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="pl-9">
|
||||
<SelectValue placeholder="Select client..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients?.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
<div>
|
||||
<div className="font-medium">{client.name}</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{client.email}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{(!clients || clients.length === 0) && (
|
||||
<p className="text-sm text-red-600">
|
||||
No clients found.{" "}
|
||||
<Link
|
||||
href="/dashboard/clients/new"
|
||||
className="underline hover:text-red-700"
|
||||
>
|
||||
Create one first
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Line Items */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Edit3 className="h-5 w-5 text-emerald-600" />
|
||||
Line Items ({formData.items.length})
|
||||
</CardTitle>
|
||||
<Button
|
||||
onClick={handleAddItem}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Add Item</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{formData.items.map((item, index) => (
|
||||
<InvoiceItemCard
|
||||
key={item.tempId}
|
||||
item={item}
|
||||
index={index}
|
||||
onUpdate={handleItemUpdate}
|
||||
onDelete={handleItemDelete}
|
||||
_isLast={index === formData.items.length - 1}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tax & Totals */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5 text-emerald-600" />
|
||||
Tax & Totals
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Tax Rate (%)</Label>
|
||||
<NumberInput
|
||||
value={formData.taxRate}
|
||||
onChange={(value) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
taxRate: value,
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
suffix="%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Notes</Label>
|
||||
<Textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, notes: e.target.value })
|
||||
}
|
||||
placeholder="Payment terms, additional notes..."
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/20 rounded-lg border p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Subtotal:</span>
|
||||
<span className="font-mono font-medium">
|
||||
${calculateSubtotal().toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Tax ({formData.taxRate}%):
|
||||
</span>
|
||||
<span className="font-mono font-medium">
|
||||
${calculateTax().toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total:</span>
|
||||
<span className="font-mono text-emerald-600">
|
||||
${calculateTotal().toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
ref={footerRef}
|
||||
className="flex flex-col gap-3 border-t pt-6 sm:flex-row sm:justify-between"
|
||||
>
|
||||
<Link href="/dashboard/invoices">
|
||||
<Button variant="outline" className="w-full sm:w-auto">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={isLoading || !isFormValid()}
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateInvoice}
|
||||
disabled={isLoading || !isFormValid()}
|
||||
className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 sm:w-auto"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create Invoice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<FloatingActionBar triggerRef={footerRef} title="Creating a new invoice">
|
||||
<Link href="/dashboard/invoices">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
className="border-border/40 hover:bg-accent/50"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={isLoading || !isFormValid()}
|
||||
variant="outline"
|
||||
className="border-border/40 hover:bg-accent/50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateInvoice}
|
||||
disabled={isLoading || !isFormValid()}
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create Invoice
|
||||
</Button>
|
||||
</FloatingActionBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,49 @@
|
||||
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 { Plus, Upload } from "lucide-react";
|
||||
import { InvoicesTable } from "./_components/invoices-table";
|
||||
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
||||
import { DataTableSkeleton } from "~/components/ui/data-table";
|
||||
|
||||
// Invoices Table Component
|
||||
async function InvoicesTable() {
|
||||
const invoices = await api.invoices.getAll();
|
||||
|
||||
return <InvoicesDataTable invoices={invoices} />;
|
||||
}
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Invoices
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Manage your invoices and payments.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="border-gray-200 bg-white/80 font-medium text-gray-700 shadow-lg hover:bg-gray-50 hover:shadow-xl"
|
||||
>
|
||||
<Link href="/dashboard/invoices/import">
|
||||
<Upload className="mr-2 h-5 w-5" /> Import CSV
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
||||
>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-5 w-5" /> Add Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<PageHeader
|
||||
title="Invoices"
|
||||
description="Manage your invoices and track payments"
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild variant="outline" className="shadow-sm">
|
||||
<Link href="/dashboard/invoices/import">
|
||||
<Upload className="mr-2 h-5 w-5" />
|
||||
<span>Import CSV</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||
>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>Create Invoice</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<InvoicesTable />
|
||||
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
||||
<InvoicesTable />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,29 @@ import { Navbar } from "~/components/Navbar";
|
||||
import { Sidebar } from "~/components/Sidebar";
|
||||
import { DashboardBreadcrumbs } from "~/components/dashboard-breadcrumbs";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<Sidebar />
|
||||
{/* Mobile layout - no left margin */}
|
||||
<main className="min-h-screen pt-24 md:hidden">
|
||||
<div className="px-4 sm:px-6 pt-4 pb-6">
|
||||
<main className="min-h-screen pt-20 md:hidden">
|
||||
<div className="px-4 pt-4 pb-6 sm:px-6">
|
||||
<DashboardBreadcrumbs />
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
{/* Desktop layout - with sidebar margin */}
|
||||
<main className="min-h-screen pt-24 hidden md:block ml-70">
|
||||
<div className="px-8 pt-6 pb-6">
|
||||
<main className="hidden min-h-screen pt-20 md:ml-[276px] md:block">
|
||||
<div className="px-6 pt-6 pb-6">
|
||||
<DashboardBreadcrumbs />
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-15
@@ -1,31 +1,36 @@
|
||||
import { auth } from "~/server/auth";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import {
|
||||
DashboardStats,
|
||||
DashboardCards,
|
||||
DashboardActivity,
|
||||
} from "./_components/dashboard-components";
|
||||
import { DashboardPageHeader } from "~/components/page-header";
|
||||
import { PageContent, PageSection } from "~/components/ui/page-layout";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-4xl font-bold text-transparent">
|
||||
Welcome back, {session?.user?.name?.split(" ")[0] ?? "User"}!
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-gray-600 dark:text-gray-300">
|
||||
Here's what's happening with your invoicing business
|
||||
</p>
|
||||
</div>
|
||||
<PageContent>
|
||||
<DashboardPageHeader
|
||||
title={`Welcome back, ${session?.user?.name?.split(" ")[0] ?? "User"}!`}
|
||||
description="Here's what's happening with your invoicing business"
|
||||
/>
|
||||
|
||||
<HydrateClient>
|
||||
<DashboardStats />
|
||||
<DashboardCards />
|
||||
<DashboardActivity />
|
||||
<PageSection>
|
||||
<DashboardStats />
|
||||
</PageSection>
|
||||
|
||||
<PageSection>
|
||||
<DashboardCards />
|
||||
</PageSection>
|
||||
|
||||
<PageSection>
|
||||
<DashboardActivity />
|
||||
</PageSection>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
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();
|
||||
@@ -230,34 +231,26 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-4xl font-bold text-transparent dark:from-emerald-400 dark:to-teal-400">
|
||||
Settings
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-gray-600 dark:text-gray-300">
|
||||
Manage your account and data preferences
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Manage your account and data preferences"
|
||||
variant="large-gradient"
|
||||
/>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
{/* Profile Section */}
|
||||
<Card className="dark:border-gray-700 dark:bg-gray-800/80">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 dark:text-white">
|
||||
<User className="h-5 w-5 dark:text-emerald-400" />
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-emerald-600" />
|
||||
Profile
|
||||
</CardTitle>
|
||||
<CardDescription className="dark:text-gray-300">
|
||||
Update your personal information
|
||||
</CardDescription>
|
||||
<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" className="dark:text-gray-300">
|
||||
Name
|
||||
</Label>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
@@ -266,16 +259,14 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="dark:text-gray-300">
|
||||
Email
|
||||
</Label>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
value={session?.user?.email ?? ""}
|
||||
disabled
|
||||
className="bg-gray-50 dark:bg-gray-700 dark:text-gray-400"
|
||||
className="bg-muted"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Email cannot be changed
|
||||
</p>
|
||||
</div>
|
||||
@@ -293,41 +284,33 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Data Statistics */}
|
||||
<Card className="dark:border-gray-700 dark:bg-gray-800/80">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 dark:text-white">
|
||||
<Database className="h-5 w-5 dark:text-emerald-400" />
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-emerald-600" />
|
||||
Your Data
|
||||
</CardTitle>
|
||||
<CardDescription className="dark:text-gray-300">
|
||||
Overview of your account data
|
||||
</CardDescription>
|
||||
<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 dark:text-emerald-400">
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{dataStats?.clients ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Clients
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">Clients</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{dataStats?.businesses ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Businesses
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">Businesses</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{dataStats?.invoices ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Invoices
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">Invoices</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -335,13 +318,13 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Backup & Restore Section */}
|
||||
<Card className="dark:border-gray-700 dark:bg-gray-800/80">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 dark:text-white">
|
||||
<Shield className="h-5 w-5 dark:text-emerald-400" />
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-emerald-600" />
|
||||
Backup & Restore
|
||||
</CardTitle>
|
||||
<CardDescription className="dark:text-gray-300">
|
||||
<CardDescription>
|
||||
Export your data for backup or import from a previous backup
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -349,8 +332,8 @@ export default function SettingsPage() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Export Data */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold dark:text-white">Export Data</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<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>
|
||||
@@ -367,8 +350,8 @@ export default function SettingsPage() {
|
||||
|
||||
{/* Import Data */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold dark:text-white">Import Data</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<h3 className="font-semibold">Import Data</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Restore your data from a previous backup file.
|
||||
</p>
|
||||
<Dialog
|
||||
@@ -381,12 +364,10 @@ export default function SettingsPage() {
|
||||
Import Data
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl dark:border-gray-700 dark:bg-gray-800">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="dark:text-white">
|
||||
Import Backup Data
|
||||
</DialogTitle>
|
||||
<DialogDescription className="dark:text-gray-300">
|
||||
<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>
|
||||
@@ -424,11 +405,9 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-blue-50 p-4 dark:border dark:border-blue-800/30 dark:bg-blue-900/20">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-300">
|
||||
Backup Tips
|
||||
</h4>
|
||||
<ul className="mt-2 space-y-1 text-sm text-blue-800 dark:text-blue-200">
|
||||
<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
|
||||
@@ -443,23 +422,21 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-red-200 dark:border-red-800/50 dark:bg-gray-800/80">
|
||||
<Card className="border-red-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<CardTitle className="flex items-center gap-2 text-red-600">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Danger Zone
|
||||
</CardTitle>
|
||||
<CardDescription className="dark:text-gray-300">
|
||||
<CardDescription>
|
||||
Irreversible actions for your account data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-red-50 p-4 dark:border dark:border-red-800/30 dark:bg-red-900/20">
|
||||
<h4 className="font-medium text-red-900 dark:text-red-300">
|
||||
Delete All Data
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-red-800 dark:text-red-200">
|
||||
<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>
|
||||
@@ -469,12 +446,10 @@ export default function SettingsPage() {
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">Delete All Data</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="dark:border-gray-700 dark:bg-gray-800">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="dark:text-white">
|
||||
Are you absolutely sure?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2 dark:text-gray-300">
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<p>
|
||||
This action cannot be undone. This will permanently delete
|
||||
all your:
|
||||
@@ -487,7 +462,7 @@ export default function SettingsPage() {
|
||||
</ul>
|
||||
<p className="font-medium">
|
||||
Type{" "}
|
||||
<span className="rounded bg-gray-100 px-1 font-mono dark:bg-gray-700 dark:text-gray-200">
|
||||
<span className="bg-muted rounded px-1 font-mono">
|
||||
DELETE ALL DATA
|
||||
</span>{" "}
|
||||
to confirm:
|
||||
|
||||
Reference in New Issue
Block a user