Add Turso/Vercel deployment configuration

- Updated database connection to support Turso auth token
- Added vercel.json with bun build configuration
- Updated environment schema for production deployment
- Added new features and components for production readiness
This commit is contained in:
2025-07-12 01:42:43 -04:00
parent 2d217fab47
commit a1b40e7a9c
75 changed files with 8821 additions and 1803 deletions

View File

@@ -3,29 +3,31 @@ import Link from "next/link";
import { useSession, signOut } from "next-auth/react";
import { Button } from "~/components/ui/button";
import { Logo } from "./logo";
import { SidebarTrigger } from "./SidebarTrigger";
export function Navbar() {
const { data: session } = useSession();
return (
<header className="fixed top-6 left-6 right-6 z-30">
<header className="fixed top-4 left-4 right-4 md:top-6 md:left-6 md:right-6 z-30">
<div className="bg-white/60 backdrop-blur-md shadow-2xl rounded-xl border-0">
<div className="flex h-16 items-center justify-between px-8">
<div className="flex items-center gap-6">
<div className="flex h-14 md:h-16 items-center justify-between px-4 md:px-8">
<div className="flex items-center gap-4 md:gap-6">
<SidebarTrigger />
<Link href="/dashboard" className="flex items-center gap-2">
<Logo size="md" />
</Link>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 md:gap-4">
{session?.user ? (
<>
<span className="text-sm text-gray-700 hidden sm:inline font-medium">
<span className="text-xs md:text-sm text-gray-700 hidden sm:inline font-medium">
{session.user.name ?? session.user.email}
</span>
<Button
variant="outline"
size="sm"
onClick={() => signOut({ callbackUrl: "/" })}
className="border-gray-300 text-gray-700 hover:bg-gray-50"
className="border-gray-300 text-gray-700 hover:bg-gray-50 text-xs md:text-sm"
>
Sign Out
</Button>
@@ -36,7 +38,7 @@ export function Navbar() {
<Button
variant="ghost"
size="sm"
className="text-gray-700 hover:bg-gray-100"
className="text-gray-700 hover:bg-gray-100 text-xs md:text-sm"
>
Sign In
</Button>
@@ -44,7 +46,7 @@ export function Navbar() {
<Link href="/auth/register">
<Button
size="sm"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium text-xs md:text-sm"
>
Register
</Button>

View File

@@ -2,72 +2,19 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { Button } from "~/components/ui/button";
import { MenuIcon, Settings, LayoutDashboard, Users, FileText } from "lucide-react";
import { useState } from "react";
import { Settings, LayoutDashboard, Users, FileText, Building } from "lucide-react";
const navLinks = [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Clients", href: "/dashboard/clients", icon: Users },
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
];
export function Sidebar() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
return (
<>
{/* Mobile trigger */}
<div className="md:hidden p-2">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="icon" aria-label="Open sidebar">
<MenuIcon className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-64 bg-white/95 border-0 rounded-r-xl backdrop-blur-sm">
<nav className="flex flex-col gap-1 p-4">
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Main</div>
{navLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
aria-current={pathname === link.href ? "page" : undefined}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-base font-medium transition-all duration-200 ${
pathname === link.href
? "bg-emerald-100 text-emerald-700 shadow-lg"
: "text-gray-700 hover:bg-gray-100"
}`}
onClick={() => setOpen(false)}
>
<Icon className="h-5 w-5" />
{link.name}
</Link>
);
})}
<div className="border-t border-gray-200 my-4" />
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Account</div>
<Link
href="/dashboard/settings"
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-base font-medium transition-all duration-200 ${
pathname === "/dashboard/settings"
? "bg-emerald-100 text-emerald-700 shadow-lg"
: "text-gray-700 hover:bg-gray-100"
}`}
onClick={() => setOpen(false)}
>
<Settings className="h-5 w-5" />
Settings
</Link>
</nav>
</SheetContent>
</Sheet>
</div>
{/* Desktop sidebar */}
<aside className="hidden md:flex flex-col justify-between fixed left-6 top-28 bottom-6 w-64 z-20 bg-white/60 backdrop-blur-md shadow-2xl rounded-xl border-0 p-8">
<nav className="flex flex-col gap-1">
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Main</div>
@@ -106,6 +53,5 @@ export function Sidebar() {
</Link>
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,81 @@
"use client";
import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } from "~/components/ui/sheet";
import { Button } from "~/components/ui/button";
import { MenuIcon, Settings, LayoutDashboard, Users, FileText } from "lucide-react";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
const navLinks = [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Clients", href: "/dashboard/clients", icon: Users },
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
];
export function SidebarTrigger() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label="Open sidebar"
className="md:hidden bg-white/80 backdrop-blur-sm border-gray-200 shadow-lg hover:bg-white h-8 w-8"
>
<MenuIcon className="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="p-0 w-80 max-w-[85vw] bg-white/95 border-0 backdrop-blur-sm"
>
<SheetHeader className="p-4 border-b border-gray-200">
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
{/* Navigation */}
<nav className="flex-1 flex flex-col gap-1 p-4">
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Main</div>
{navLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
aria-current={pathname === link.href ? "page" : undefined}
className={`flex items-center gap-3 rounded-lg px-3 py-3 text-base font-medium transition-all duration-200 ${
pathname === link.href
? "bg-emerald-100 text-emerald-700 shadow-lg"
: "text-gray-700 hover:bg-gray-100"
}`}
onClick={() => setOpen(false)}
>
<Icon className="h-5 w-5" />
{link.name}
</Link>
);
})}
<div className="border-t border-gray-200 my-4" />
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Account</div>
<Link
href="/dashboard/settings"
className={`flex items-center gap-3 rounded-lg px-3 py-3 text-base font-medium transition-all duration-200 ${
pathname === "/dashboard/settings"
? "bg-emerald-100 text-emerald-700 shadow-lg"
: "text-gray-700 hover:bg-gray-100"
}`}
onClick={() => setOpen(false)}
>
<Settings className="h-5 w-5" />
Settings
</Link>
</nav>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,454 @@
"use client";
import { Building, Mail, MapPin, Phone, Save, Globe, BadgeDollarSign, Image, Star } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { SearchableSelect } from "~/components/ui/select";
import { FormSkeleton } from "~/components/ui/skeleton";
import { api } from "~/trpc/react";
interface BusinessFormProps {
businessId?: string;
mode: "create" | "edit";
}
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const router = useRouter();
const [formData, setFormData] = useState({
name: "",
email: "",
phone: "",
addressLine1: "",
addressLine2: "",
city: "",
state: "",
postalCode: "",
country: "",
website: "",
taxId: "",
logoUrl: "",
isDefault: false,
});
const [loading, setLoading] = useState(false);
// Fetch business data if editing
const { data: business, isLoading: isLoadingBusiness } = api.businesses.getById.useQuery(
{ id: businessId! },
{ enabled: mode === "edit" && !!businessId }
);
const createBusiness = api.businesses.create.useMutation({
onSuccess: () => {
toast.success("Business created successfully");
router.push("/dashboard/businesses");
},
onError: (error) => {
toast.error(error.message || "Failed to create business");
},
});
const updateBusiness = api.businesses.update.useMutation({
onSuccess: () => {
toast.success("Business updated successfully");
router.push("/dashboard/businesses");
},
onError: (error) => {
toast.error(error.message || "Failed to update business");
},
});
// Load business data when editing
useEffect(() => {
if (business && mode === "edit") {
setFormData({
name: business.name,
email: business.email ?? "",
phone: business.phone ?? "",
addressLine1: business.addressLine1 ?? "",
addressLine2: business.addressLine2 ?? "",
city: business.city ?? "",
state: business.state ?? "",
postalCode: business.postalCode ?? "",
country: business.country ?? "",
website: business.website ?? "",
taxId: business.taxId ?? "",
logoUrl: business.logoUrl ?? "",
isDefault: business.isDefault ?? false,
});
}
}, [business, mode]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (mode === "create") {
await createBusiness.mutateAsync(formData);
} else {
await updateBusiness.mutateAsync({
id: businessId!,
...formData,
});
}
} finally {
setLoading(false);
}
};
const handleInputChange = (field: string, value: string | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
// Phone number formatting (reuse from client-form)
const formatPhoneNumber = (value: string) => {
const phoneNumber = value.replace(/\D/g, '');
if (phoneNumber.length <= 3) {
return phoneNumber;
} else if (phoneNumber.length <= 6) {
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}`;
} else {
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 10)}`;
}
};
const handlePhoneChange = (value: string) => {
const formatted = formatPhoneNumber(value);
handleInputChange("phone", formatted);
};
// US States and Countries (reuse from client-form)
const US_STATES = [
{ value: "__placeholder__", label: "Select State" },
{ value: "AL", label: "Alabama" },
{ value: "AK", label: "Alaska" },
{ value: "AZ", label: "Arizona" },
{ value: "AR", label: "Arkansas" },
{ value: "CA", label: "California" },
{ value: "CO", label: "Colorado" },
{ value: "CT", label: "Connecticut" },
{ value: "DE", label: "Delaware" },
{ value: "FL", label: "Florida" },
{ value: "GA", label: "Georgia" },
{ value: "HI", label: "Hawaii" },
{ value: "ID", label: "Idaho" },
{ value: "IL", label: "Illinois" },
{ value: "IN", label: "Indiana" },
{ value: "IA", label: "Iowa" },
{ value: "KS", label: "Kansas" },
{ value: "KY", label: "Kentucky" },
{ value: "LA", label: "Louisiana" },
{ value: "ME", label: "Maine" },
{ value: "MD", label: "Maryland" },
{ value: "MA", label: "Massachusetts" },
{ value: "MI", label: "Michigan" },
{ value: "MN", label: "Minnesota" },
{ value: "MS", label: "Mississippi" },
{ value: "MO", label: "Missouri" },
{ value: "MT", label: "Montana" },
{ value: "NE", label: "Nebraska" },
{ value: "NV", label: "Nevada" },
{ value: "NH", label: "New Hampshire" },
{ value: "NJ", label: "New Jersey" },
{ value: "NM", label: "New Mexico" },
{ value: "NY", label: "New York" },
{ value: "NC", label: "North Carolina" },
{ value: "ND", label: "North Dakota" },
{ value: "OH", label: "Ohio" },
{ value: "OK", label: "Oklahoma" },
{ value: "OR", label: "Oregon" },
{ value: "PA", label: "Pennsylvania" },
{ value: "RI", label: "Rhode Island" },
{ value: "SC", label: "South Carolina" },
{ value: "SD", label: "South Dakota" },
{ value: "TN", label: "Tennessee" },
{ value: "TX", label: "Texas" },
{ value: "UT", label: "Utah" },
{ value: "VT", label: "Vermont" },
{ value: "VA", label: "Virginia" },
{ value: "WA", label: "Washington" },
{ value: "WV", label: "West Virginia" },
{ value: "WI", label: "Wisconsin" },
{ value: "WY", label: "Wyoming" }
];
const MOST_USED_COUNTRIES = [
{ value: "United States", label: "United States" },
{ value: "United Kingdom", label: "United Kingdom" },
{ value: "Canada", label: "Canada" },
{ value: "Australia", label: "Australia" },
{ value: "Germany", label: "Germany" },
{ value: "France", label: "France" },
{ value: "India", label: "India" }
];
const ALL_COUNTRIES = [
"Afghanistan", "Albania", "Algeria", "Andorra", "Angola", "Antigua and Barbuda", "Argentina", "Armenia", "Australia", "Austria", "Azerbaijan", "Bahamas", "Bahrain", "Bangladesh", "Barbados", "Belarus", "Belgium", "Belize", "Benin", "Bhutan", "Bolivia", "Bosnia and Herzegovina", "Botswana", "Brazil", "Brunei", "Bulgaria", "Burkina Faso", "Burundi", "Cabo Verde", "Cambodia", "Cameroon", "Canada", "Central African Republic", "Chad", "Chile", "China", "Colombia", "Comoros", "Congo", "Costa Rica", "Croatia", "Cuba", "Cyprus", "Czech Republic", "Democratic Republic of the Congo", "Denmark", "Djibouti", "Dominica", "Dominican Republic", "East Timor", "Ecuador", "Egypt", "El Salvador", "Equatorial Guinea", "Eritrea", "Estonia", "Eswatini", "Ethiopia", "Fiji", "Finland", "France", "Gabon", "Gambia", "Georgia", "Germany", "Ghana", "Greece", "Grenada", "Guatemala", "Guinea", "Guinea-Bissau", "Guyana", "Haiti", "Honduras", "Hungary", "Iceland", "India", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", "Italy", "Ivory Coast", "Jamaica", "Japan", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "Kuwait", "Kyrgyzstan", "Laos", "Latvia", "Lebanon", "Lesotho", "Liberia", "Libya", "Liechtenstein", "Lithuania", "Luxembourg", "Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands", "Mauritania", "Mauritius", "Mexico", "Micronesia", "Moldova", "Monaco", "Mongolia", "Montenegro", "Morocco", "Mozambique", "Myanmar", "Namibia", "Nauru", "Nepal", "Netherlands", "New Zealand", "Nicaragua", "Niger", "Nigeria", "North Korea", "North Macedonia", "Norway", "Oman", "Pakistan", "Palau", "Palestine", "Panama", "Papua New Guinea", "Paraguay", "Peru", "Philippines", "Poland", "Portugal", "Qatar", "Romania", "Russia", "Rwanda", "Saint Kitts and Nevis", "Saint Lucia", "Saint Vincent and the Grenadines", "Samoa", "San Marino", "Sao Tome and Principe", "Saudi Arabia", "Senegal", "Serbia", "Seychelles", "Sierra Leone", "Singapore", "Slovakia", "Slovenia", "Solomon Islands", "Somalia", "South Africa", "South Korea", "South Sudan", "Spain", "Sri Lanka", "Sudan", "Suriname", "Sweden", "Switzerland", "Syria", "Taiwan", "Tajikistan", "Tanzania", "Thailand", "Togo", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey", "Turkmenistan", "Tuvalu", "Uganda", "Ukraine", "United Arab Emirates", "United Kingdom", "United States", "Uruguay", "Uzbekistan", "Vanuatu", "Vatican City", "Venezuela", "Vietnam", "Yemen", "Zambia", "Zimbabwe"
];
const OTHER_COUNTRIES = ALL_COUNTRIES
.filter(c => !MOST_USED_COUNTRIES.some(mc => mc.value === c))
.map(country => ({ value: country, label: country }))
.sort((a, b) => a.label.localeCompare(b.label));
const ALL_COUNTRIES_OPTIONS = [
{ value: "__placeholder__", label: "Select country" },
...MOST_USED_COUNTRIES,
...OTHER_COUNTRIES
];
if (mode === "edit" && isLoadingBusiness) {
return (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
<CardContent className="p-8">
<FormSkeleton />
</CardContent>
</Card>
);
}
return (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
<CardContent>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Basic Information Section */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700">
<Building className="h-5 w-5" />
<h3 className="text-lg font-semibold">Business Information</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium text-gray-700">
Business Name *
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
required
placeholder="Enter business name"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium text-gray-700">
Email Address
</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
placeholder="business@example.com"
className="h-12 pl-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
</div>
</div>
</div>
{/* Contact Information Section */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700">
<Phone className="h-5 w-5" />
<h3 className="text-lg font-semibold">Contact Information</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="phone" className="text-sm font-medium text-gray-700">
Phone Number
</Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handlePhoneChange(e.target.value)}
placeholder="(555) 123-4567"
className="h-12 pl-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="website" className="text-sm font-medium text-gray-700">
Website
</Label>
<div className="relative">
<Globe className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
id="website"
type="url"
value={formData.website}
onChange={(e) => handleInputChange("website", e.target.value)}
placeholder="https://yourbusiness.com"
className="h-12 pl-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
</div>
</div>
</div>
{/* Address Section */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700">
<MapPin className="h-5 w-5" />
<h3 className="text-lg font-semibold">Address</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="addressLine1" className="text-sm font-medium text-gray-700">
Address Line 1
</Label>
<Input
id="addressLine1"
value={formData.addressLine1}
onChange={(e) => handleInputChange("addressLine1", e.target.value)}
placeholder="123 Main St"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="addressLine2" className="text-sm font-medium text-gray-700">
Address Line 2
</Label>
<Input
id="addressLine2"
value={formData.addressLine2}
onChange={(e) => handleInputChange("addressLine2", e.target.value)}
placeholder="Suite 100"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="city" className="text-sm font-medium text-gray-700">
City
</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleInputChange("city", e.target.value)}
placeholder="City"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="state" className="text-sm font-medium text-gray-700">
State/Province
</Label>
<SearchableSelect
value={formData.state}
onValueChange={(value) => handleInputChange("state", value)}
options={US_STATES}
placeholder="Select State"
searchPlaceholder="Search states..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="postalCode" className="text-sm font-medium text-gray-700">
Postal Code
</Label>
<Input
id="postalCode"
value={formData.postalCode}
onChange={(e) => handleInputChange("postalCode", e.target.value)}
placeholder="ZIP or postal code"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="country" className="text-sm font-medium text-gray-700">
Country
</Label>
<SearchableSelect
value={formData.country}
onValueChange={(value) => handleInputChange("country", value)}
options={ALL_COUNTRIES_OPTIONS}
placeholder="Select country"
searchPlaceholder="Search countries..."
/>
</div>
</div>
</div>
{/* Tax, Logo, Default Section */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700">
<BadgeDollarSign className="h-5 w-5" />
<h3 className="text-lg font-semibold">Other Details</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="taxId" className="text-sm font-medium text-gray-700">
Tax ID / VAT Number
</Label>
<Input
id="taxId"
value={formData.taxId}
onChange={(e) => handleInputChange("taxId", e.target.value)}
placeholder="Tax ID or VAT number"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="logoUrl" className="text-sm font-medium text-gray-700">
Logo URL
</Label>
<div className="relative">
<Image className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
id="logoUrl"
value={formData.logoUrl}
onChange={(e) => handleInputChange("logoUrl", e.target.value)}
placeholder="https://yourbusiness.com/logo.png"
className="h-12 pl-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
</div>
<div className="flex items-center space-x-2 mt-4">
<input
id="isDefault"
type="checkbox"
checked={formData.isDefault}
onChange={(e) => handleInputChange("isDefault", e.target.checked)}
className="h-5 w-5 text-emerald-600 border-gray-300 rounded focus:ring-emerald-500"
/>
<Label htmlFor="isDefault" className="text-sm font-medium text-gray-700 flex items-center">
<Star className="h-4 w-4 mr-1 text-yellow-400" /> Set as default business
</Label>
</div>
</div>
</div>
<div className="flex justify-end gap-4 mt-8">
<Button
type="button"
variant="outline"
asChild
className="border-gray-300"
disabled={loading}
>
<Link href="/dashboard/businesses">Cancel</Link>
</Button>
<Button
type="submit"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl"
disabled={loading}
>
<Save className="mr-2 h-5 w-5" />
{mode === "create" ? "Create Business" : "Save Changes"}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -9,6 +9,7 @@ import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { FormSkeleton } from "~/components/ui/skeleton";
import { api } from "~/trpc/react";
interface ClientFormProps {
@@ -129,17 +130,9 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
if (mode === "edit" && isLoadingClient) {
return (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle>Loading client...</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="h-10 bg-muted rounded animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
<div className="h-20 bg-muted rounded animate-pulse" />
</div>
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
<CardContent className="p-8">
<FormSkeleton />
</CardContent>
</Card>
);
@@ -147,25 +140,6 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
return (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
{/* <CardHeader className="text-center pb-8"> */}
{/* <div className="flex items-center justify-center space-x-4 mb-4"> */}
{/* <Link href="/dashboard/clients">
<Button variant="ghost" size="sm" className="hover:bg-white/50">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Clients
</Button>
</Link> */}
{/* </div> */}
{/* <CardTitle className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
{mode === "create" ? "Add New Client" : "Edit Client"}
</CardTitle> */}
{/* <p className="text-muted-foreground mt-2">
{mode === "create"
? "Create a new client profile with complete contact information"
: "Update your client's information"
}
</p> */}
{/* </CardHeader> */}
<CardContent>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Basic Information Section */}
@@ -250,7 +224,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
id="addressLine1"
value={formData.addressLine1}
onChange={(e) => handleInputChange("addressLine1", e.target.value)}
placeholder="Street address, P.O. box, company name"
placeholder="123 Main Street"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
@@ -262,10 +236,12 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
id="addressLine2"
value={formData.addressLine2}
onChange={(e) => handleInputChange("addressLine2", e.target.value)}
placeholder="Apartment, suite, unit, building, floor, etc."
placeholder="Suite 100"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label htmlFor="city" className="text-sm font-medium text-gray-700">
City
@@ -274,23 +250,24 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
id="city"
value={formData.city}
onChange={(e) => handleInputChange("city", e.target.value)}
placeholder="City or town"
placeholder="New York"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="state" className="text-sm font-medium text-gray-700">
State
State / Province
</Label>
<select
id="state"
value={formData.state}
onChange={e => handleInputChange("state", e.target.value)}
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
onChange={(e) => handleInputChange("state", e.target.value)}
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
>
<option value="">Select a state</option>
{US_STATES.filter(s => s).map(state => (
<option key={state} value={state}>{state}</option>
{US_STATES.map((state) => (
<option key={state} value={state}>
{state || "Select State"}
</option>
))}
</select>
</div>
@@ -302,10 +279,11 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
id="postalCode"
value={formData.postalCode}
onChange={(e) => handleInputChange("postalCode", e.target.value)}
placeholder="ZIP or postal code"
placeholder="12345"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="country" className="text-sm font-medium text-gray-700">
Country
@@ -313,41 +291,55 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
<select
id="country"
value={formData.country}
onChange={e => handleInputChange("country", e.target.value)}
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
onChange={(e) => handleInputChange("country", e.target.value)}
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
>
<option value="">Select a country</option>
{MOST_USED_COUNTRIES.map(country => (
<option key={country} value={country}>{country}</option>
<option value="">Select Country</option>
<optgroup label="Most Used">
{MOST_USED_COUNTRIES.map((country) => (
<option key={country} value={country}>
{country}
</option>
))}
<option disabled></option>
{OTHER_COUNTRIES.map(country => (
<option key={country} value={country}>{country}</option>
</optgroup>
<optgroup label="All Countries">
{OTHER_COUNTRIES.map((country) => (
<option key={country} value={country}>
{country}
</option>
))}
</optgroup>
</select>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-4 pt-6 border-t border-gray-200">
{/* Submit Button */}
<div className="flex gap-3 pt-6">
<Button
type="submit"
disabled={loading}
className="flex-1 h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
{loading ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{mode === "create" ? "Creating..." : "Updating..."}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{loading ? "Saving..." : mode === "create" ? "Create Client" : "Update Client"}
{mode === "create" ? "Create Client" : "Update Client"}
</>
)}
</Button>
<Link href="/dashboard/clients" className="flex-1">
<Button
type="button"
variant="outline"
className="w-full h-12 border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
onClick={() => router.push("/dashboard/clients")}
className="border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
>
Cancel
</Button>
</Link>
</div>
</form>
</CardContent>

View File

@@ -0,0 +1,232 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Badge } from "~/components/ui/badge";
import {
Sun,
Moon,
Palette,
Check,
X,
Info,
AlertCircle,
Settings,
User,
Mail
} from "lucide-react";
export function DarkModeTest() {
return (
<div className="min-h-screen p-8 space-y-8">
{/* Header */}
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold text-gray-900 dark:text-white">
Dark Mode Test Suite
</h1>
<p className="text-lg text-gray-600 dark:text-gray-300">
Testing media query-based dark mode implementation
</p>
<div className="flex items-center justify-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Sun className="h-4 w-4" />
<span>Light Mode</span>
</div>
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Moon className="h-4 w-4" />
<span>Dark Mode (Auto)</span>
</div>
</div>
</div>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{/* Color Test Card */}
<Card className="dark:bg-gray-800">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Color Tests
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<p className="text-sm text-gray-500 dark:text-gray-400">Text Colors:</p>
<div className="text-gray-900 dark:text-white">Primary Text</div>
<div className="text-gray-700 dark:text-gray-300">Secondary Text</div>
<div className="text-gray-500 dark:text-gray-400">Muted Text</div>
<div className="text-green-600 dark:text-green-400">Success Text</div>
<div className="text-red-600 dark:text-red-400">Error Text</div>
</div>
</CardContent>
</Card>
{/* Button Test Card */}
<Card className="dark:bg-gray-800">
<CardHeader>
<CardTitle>Button Variants</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button size="sm">Default</Button>
<Button variant="secondary" size="sm">Secondary</Button>
<Button variant="outline" size="sm">Outline</Button>
<Button variant="ghost" size="sm">Ghost</Button>
<Button variant="destructive" size="sm">Destructive</Button>
</div>
</CardContent>
</Card>
{/* Form Elements Card */}
<Card className="dark:bg-gray-800">
<CardHeader>
<CardTitle>Form Elements</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="test-input">Test Input</Label>
<div className="relative">
<Mail className="absolute top-3 left-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
<Input
id="test-input"
placeholder="Enter text here..."
className="pl-10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="test-select">Test Select</Label>
<select
id="test-select"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30"
>
<option value="">Select an option</option>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</select>
</div>
</CardContent>
</Card>
{/* Status Badges Card */}
<Card className="dark:bg-gray-800">
<CardHeader>
<CardTitle>Status Indicators</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2">
<Badge variant="default">Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="destructive">Error</Badge>
<Badge variant="outline">Outline</Badge>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Check className="h-4 w-4 text-green-500" />
<span className="text-gray-700 dark:text-gray-300">Success Status</span>
</div>
<div className="flex items-center gap-2 text-sm">
<X className="h-4 w-4 text-red-500" />
<span className="text-gray-700 dark:text-gray-300">Error Status</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Info className="h-4 w-4 text-blue-500" />
<span className="text-gray-700 dark:text-gray-300">Info Status</span>
</div>
<div className="flex items-center gap-2 text-sm">
<AlertCircle className="h-4 w-4 text-yellow-500" />
<span className="text-gray-700 dark:text-gray-300">Warning Status</span>
</div>
</div>
</CardContent>
</Card>
{/* Background Test Card */}
<Card className="dark:bg-gray-800">
<CardHeader>
<CardTitle>Background Tests</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
<p className="text-sm text-gray-700 dark:text-gray-300">Light Background</p>
</div>
<div className="p-3 bg-gray-100 dark:bg-gray-600 rounded-md">
<p className="text-sm text-gray-700 dark:text-gray-300">Medium Background</p>
</div>
<div className="p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md">
<p className="text-sm text-gray-700 dark:text-gray-300">Card Background</p>
</div>
</div>
</CardContent>
</Card>
{/* Icon Test Card */}
<Card className="dark:bg-gray-800">
<CardHeader>
<CardTitle>Icon Colors</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-4 gap-4">
<div className="flex flex-col items-center gap-1">
<User className="h-6 w-6 text-gray-700 dark:text-gray-300" />
<span className="text-xs text-gray-500 dark:text-gray-400">Default</span>
</div>
<div className="flex flex-col items-center gap-1">
<Settings className="h-6 w-6 text-green-600 dark:text-green-400" />
<span className="text-xs text-gray-500 dark:text-gray-400">Success</span>
</div>
<div className="flex flex-col items-center gap-1">
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400" />
<span className="text-xs text-gray-500 dark:text-gray-400">Error</span>
</div>
<div className="flex flex-col items-center gap-1">
<Info className="h-6 w-6 text-blue-600 dark:text-blue-400" />
<span className="text-xs text-gray-500 dark:text-gray-400">Info</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* System Information */}
<Card className="dark:bg-gray-800">
<CardHeader>
<CardTitle>System Information</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Dark Mode Method:</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Media Query (@media (prefers-color-scheme: dark))</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Tailwind Config:</p>
<p className="text-sm text-gray-500 dark:text-gray-400">darkMode: "media"</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">CSS Variables:</p>
<p className="text-sm text-gray-500 dark:text-gray-400">oklch() color space</p>
</div>
</div>
</CardContent>
</Card>
{/* Instructions */}
<Card className="border-blue-200 dark:border-blue-800 dark:bg-gray-800">
<CardHeader>
<CardTitle className="text-blue-700 dark:text-blue-300">Testing Instructions</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-blue-600 dark:text-blue-400">
<p> Change your system theme between light and dark to test automatic switching</p>
<p> All UI elements should adapt colors automatically</p>
<p> Text should remain readable in both modes</p>
<p> Icons and buttons should have appropriate contrast</p>
<p> Form elements should be clearly visible and functional</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useState, useEffect } from "react";
import { Sun, Moon, Monitor } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
type Theme = "light" | "dark" | "system";
export function DarkModeToggle() {
const [theme, setTheme] = useState<Theme>("system");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
// Get stored theme preference or default to system
const storedTheme = localStorage.getItem("theme") as Theme | null;
setTheme(storedTheme || "system");
// Listen for system preference changes when using system theme
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleSystemChange = () => {
const currentTheme = localStorage.getItem("theme");
if (!currentTheme || currentTheme === "system") {
applyTheme("system");
}
};
mediaQuery.addEventListener("change", handleSystemChange);
return () => {
mediaQuery.removeEventListener("change", handleSystemChange);
};
}, []);
const applyTheme = (newTheme: Theme) => {
const root = document.documentElement;
if (newTheme === "light") {
root.classList.remove("dark");
root.classList.add("light");
} else if (newTheme === "dark") {
root.classList.remove("light");
root.classList.add("dark");
} else {
// System theme - remove manual classes and let CSS media query handle it
root.classList.remove("light", "dark");
const systemDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
if (systemDark) {
root.classList.add("dark");
}
}
};
const handleThemeChange = (newTheme: Theme) => {
setTheme(newTheme);
if (newTheme === "system") {
localStorage.removeItem("theme");
} else {
localStorage.setItem("theme", newTheme);
}
applyTheme(newTheme);
};
// Don't render until mounted to avoid hydration mismatch
if (!mounted) {
return (
<Button variant="ghost" size="icon" className="h-9 w-9">
<Monitor className="h-4 w-4" />
</Button>
);
}
const getIcon = () => {
switch (theme) {
case "light":
return <Sun className="h-4 w-4" />;
case "dark":
return <Moon className="h-4 w-4" />;
case "system":
return <Monitor className="h-4 w-4" />;
}
};
const getLabel = () => {
switch (theme) {
case "light":
return "Light mode";
case "dark":
return "Dark mode";
case "system":
return "System theme";
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
aria-label={getLabel()}
>
{getIcon()}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => handleThemeChange("light")}
className={theme === "light" ? "bg-accent" : ""}
>
<Sun className="mr-2 h-4 w-4" />
Light
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleThemeChange("dark")}
className={theme === "dark" ? "bg-accent" : ""}
>
<Moon className="mr-2 h-4 w-4" />
Dark
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleThemeChange("system")}
className={theme === "system" ? "bg-accent" : ""}
>
<Monitor className="mr-2 h-4 w-4" />
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -6,6 +6,8 @@ import Link from "next/link";
import { ChevronRight } from "lucide-react";
import React from "react";
import { api } from "~/trpc/react";
import { format } from "date-fns";
import { Skeleton } from "~/components/ui/skeleton";
function isUUID(str: string) {
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(str);
@@ -20,11 +22,21 @@ export function DashboardBreadcrumbs() {
if (segments[1] === "clients" && segments[2] && isUUID(segments[2])) {
clientId = segments[2];
}
const { data: client } = api.clients.getById.useQuery(
const { data: client, isLoading: clientLoading } = api.clients.getById.useQuery(
{ id: clientId ?? "" },
{ enabled: !!clientId }
);
// Find invoiceId if present
let invoiceId: string | undefined = undefined;
if (segments[1] === "invoices" && segments[2] && isUUID(segments[2])) {
invoiceId = segments[2];
}
const { data: invoice, isLoading: invoiceLoading } = api.invoices.getById.useQuery(
{ id: invoiceId ?? "" },
{ enabled: !!invoiceId }
);
// Generate breadcrumb items based on pathname
const breadcrumbs = React.useMemo(() => {
const items = [];
@@ -32,9 +44,16 @@ export function DashboardBreadcrumbs() {
const segment = segments[i];
const path = `/${segments.slice(0, i + 1).join('/')}`;
if (segment === 'dashboard') continue;
let label = segment;
let label: string | React.ReactElement = segment ?? "";
if (segment === 'clients') label = 'Clients';
if (isUUID(segment ?? "") && client) label = client.name ?? "";
if (isUUID(segment ?? "") && clientLoading) label = <Skeleton className="h-5 w-24 inline-block align-middle" />;
else if (isUUID(segment ?? "") && client) label = client.name ?? "";
if (isUUID(segment ?? "") && invoiceLoading) label = <Skeleton className="h-5 w-24 inline-block align-middle" />;
else if (isUUID(segment ?? "") && invoice) {
const issueDate = new Date(invoice.issueDate);
label = format(issueDate, "MMM dd, yyyy");
}
if (segment === 'invoices') label = 'Invoices';
if (segment === 'new') label = 'New';
// Only show 'Edit' if not the last segment
@@ -49,29 +68,29 @@ export function DashboardBreadcrumbs() {
});
}
return items;
}, [segments, client]);
}, [segments, client, invoice, clientLoading, invoiceLoading]);
if (breadcrumbs.length === 0) return null;
return (
<Breadcrumb className="mb-6">
<BreadcrumbList>
<Breadcrumb className="mb-4 sm:mb-6">
<BreadcrumbList className="flex-wrap">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/dashboard">Dashboard</Link>
<Link href="/dashboard" className="text-sm sm:text-base">Dashboard</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbs.map((crumb) => (
<React.Fragment key={crumb.href}>
<BreadcrumbSeparator>
<ChevronRight className="h-4 w-4" />
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
</BreadcrumbSeparator>
<BreadcrumbItem>
{crumb.isLast ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
<BreadcrumbPage className="text-sm sm:text-base">{crumb.label}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={crumb.href}>{crumb.label}</Link>
<Link href={crumb.href} className="text-sm sm:text-base">{crumb.label}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>

View File

@@ -0,0 +1,282 @@
"use client";
import * as React from "react";
import { useEffect, useState } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Trash2, GripVertical, CalendarIcon } from "lucide-react";
import { format } from "date-fns";
import { Calendar } from "~/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
interface InvoiceItem {
id: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}
interface EditableInvoiceItemsProps {
items: InvoiceItem[];
onItemsChange: (items: InvoiceItem[]) => void;
onRemoveItem: (index: number) => void;
}
function SortableItem({
item,
index,
onItemChange,
onRemove
}: {
item: InvoiceItem;
index: number;
onItemChange: (index: number, field: string, value: any) => void;
onRemove: (index: number) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const handleItemChange = (field: string, value: any) => {
onItemChange(index, field, value);
};
return (
<div
ref={setNodeRef}
style={style}
className={`grid grid-cols-12 gap-2 items-center p-4 border border-gray-200 rounded-lg hover:border-emerald-300 transition-colors ${
isDragging ? "opacity-50 shadow-lg" : ""
}`}
>
{/* Drag Handle */}
<div className="col-span-1 flex items-center justify-center h-10">
<button
type="button"
{...attributes}
{...listeners}
className="p-2 text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing rounded hover:bg-gray-100 transition-colors"
>
<GripVertical className="h-4 w-4" />
</button>
</div>
{/* Date */}
<div className="col-span-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-between font-normal h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 text-sm"
>
{item.date ? format(item.date, "MMM dd") : "Date"}
<CalendarIcon className="h-4 w-4 text-gray-400" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={item.date}
captionLayout="dropdown"
onSelect={(selectedDate: Date | undefined) => {
handleItemChange("date", selectedDate || new Date())
}}
/>
</PopoverContent>
</Popover>
</div>
{/* Description */}
<div className="col-span-4">
<Input
value={item.description}
onChange={e => handleItemChange("description", e.target.value)}
placeholder="Work description"
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
{/* Hours */}
<div className="col-span-1">
<Input
type="number"
step="0.25"
min="0"
value={item.hours}
onChange={e => handleItemChange("hours", e.target.value)}
placeholder="0"
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
{/* Rate */}
<div className="col-span-2">
<Input
type="number"
step="0.01"
min="0"
value={item.rate}
onChange={e => handleItemChange("rate", e.target.value)}
placeholder="0.00"
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
{/* Amount */}
<div className="col-span-1">
<div className="h-10 flex items-center px-3 border border-gray-200 rounded-md bg-gray-50 text-gray-700 font-medium">
${item.amount.toFixed(2)}
</div>
</div>
{/* Remove Button */}
<div className="col-span-1">
<Button
type="button"
onClick={() => onRemove(index)}
variant="outline"
size="sm"
className="h-10 w-10 p-0 border-red-200 text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}
export function EditableInvoiceItems({ items, onItemsChange, onRemoveItem }: EditableInvoiceItemsProps) {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
const oldIndex = items.findIndex(item => item.id === active.id);
const newIndex = items.findIndex(item => item.id === over?.id);
const newItems = arrayMove(items, oldIndex, newIndex);
onItemsChange(newItems);
}
};
const handleItemChange = (index: number, field: string, value: any) => {
const newItems = [...items];
if (field === "hours" || field === "rate") {
if (newItems[index]) {
newItems[index][field as "hours" | "rate"] = parseFloat(value) || 0;
newItems[index].amount = newItems[index].hours * newItems[index].rate;
}
} else if (field === "date") {
if (newItems[index]) {
newItems[index][field as "date"] = value;
}
} else {
if (newItems[index]) {
newItems[index][field as "description"] = value;
}
}
onItemsChange(newItems);
};
// Show skeleton loading on server-side
if (!isClient) {
return (
<div className="space-y-3">
{items.map((item, index) => (
<div key={item.id} className="grid grid-cols-12 gap-2 items-center p-4 border border-gray-200 rounded-lg animate-pulse">
<div className="col-span-1 flex items-center justify-center h-10">
<div className="w-4 h-4 bg-gray-300 rounded"></div>
</div>
<div className="col-span-2">
<div className="h-10 bg-gray-300 rounded"></div>
</div>
<div className="col-span-4">
<div className="h-10 bg-gray-300 rounded"></div>
</div>
<div className="col-span-1">
<div className="h-10 bg-gray-300 rounded"></div>
</div>
<div className="col-span-2">
<div className="h-10 bg-gray-300 rounded"></div>
</div>
<div className="col-span-1">
<div className="h-10 bg-gray-300 rounded"></div>
</div>
<div className="col-span-1">
<div className="h-10 w-10 bg-gray-300 rounded"></div>
</div>
</div>
))}
</div>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={items.map(item => item.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{items.map((item, index) => (
<SortableItem
key={item.id}
item={item}
index={index}
onItemChange={handleItemChange}
onRemove={onRemoveItem}
/>
))}
</div>
</SortableContext>
</DndContext>
);
}

View File

@@ -3,17 +3,41 @@
import * as React from "react";
import { useState, useEffect } from "react";
import { api } from "~/trpc/react";
import { Card, CardContent } from "~/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { DatePicker } from "~/components/ui/date-picker";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { SearchableSelect } from "~/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { toast } from "sonner";
import { Calendar, FileText, User, Plus, Trash2 } from "lucide-react";
import {
Calendar,
FileText,
User,
Plus,
Trash2,
DollarSign,
Clock,
Edit3,
Save,
X,
AlertCircle,
Building
} from "lucide-react";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { FormSkeleton } from "~/components/ui/skeleton";
import { EditableInvoiceItems } from "~/components/editable-invoice-items";
const STATUS_OPTIONS = ["draft", "sent", "paid", "overdue"];
const STATUS_OPTIONS = [
{ value: "draft", label: "Draft", color: "bg-gray-100 text-gray-800" },
{ value: "sent", label: "Sent", color: "bg-blue-100 text-blue-800" },
{ value: "paid", label: "Paid", color: "bg-green-100 text-green-800" },
{ value: "overdue", label: "Overdue", color: "bg-red-100 text-red-800" },
] as const;
interface InvoiceFormProps {
invoiceId?: string;
@@ -23,19 +47,23 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
const [formData, setFormData] = useState({
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${Date.now().toString().slice(-6)}`,
businessId: "",
clientId: "",
issueDate: new Date(),
dueDate: new Date(),
status: "draft" as "draft" | "sent" | "paid" | "overdue",
notes: "",
taxRate: 0,
items: [
{ date: new Date(), description: "", hours: 0, rate: 0, amount: 0 },
{ id: crypto.randomUUID(), date: new Date(), description: "", hours: 0, rate: 0, amount: 0 },
],
});
const [loading, setLoading] = useState(false);
const [defaultRate, setDefaultRate] = useState(0);
// Fetch clients for dropdown
// Fetch clients and businesses for dropdowns
const { data: clients, isLoading: loadingClients } = api.clients.getAll.useQuery();
const { data: businesses, isLoading: loadingBusinesses } = api.businesses.getAll.useQuery();
// Fetch existing invoice data if editing
const { data: existingInvoice, isLoading: loadingInvoice } = api.invoices.getById.useQuery(
@@ -48,49 +76,43 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
if (existingInvoice && invoiceId) {
setFormData({
invoiceNumber: existingInvoice.invoiceNumber,
businessId: existingInvoice.businessId ?? "",
clientId: existingInvoice.clientId,
issueDate: new Date(existingInvoice.issueDate),
dueDate: new Date(existingInvoice.dueDate),
status: existingInvoice.status as "draft" | "sent" | "paid" | "overdue",
notes: existingInvoice.notes || "",
notes: existingInvoice.notes ?? "",
taxRate: existingInvoice.taxRate,
items: existingInvoice.items?.map(item => ({
id: crypto.randomUUID(),
date: new Date(item.date),
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
})) || [{ date: new Date(), description: "", hours: 0, rate: 0, amount: 0 }],
})) || [{ id: crypto.randomUUID(), date: new Date(), description: "", hours: 0, rate: 0, amount: 0 }],
});
// Set default rate from first item
if (existingInvoice.items?.[0]) {
setDefaultRate(existingInvoice.items[0].rate);
}
}
}, [existingInvoice, invoiceId]);
// Calculate total amount
const totalAmount = formData.items.reduce(
(sum, item) => sum + (item.hours * item.rate),
0
);
// Calculate totals
const totals = React.useMemo(() => {
const subtotal = formData.items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
const taxAmount = (subtotal * formData.taxRate) / 100;
const total = subtotal + taxAmount;
return {
subtotal,
taxAmount,
total,
};
}, [formData.items, formData.taxRate]);
// Update item amount on change
const handleItemChange = (idx: number, field: string, value: any) => {
setFormData((prev) => {
const items = [...prev.items];
if (field === "hours" || field === "rate") {
if (items[idx]) {
items[idx][field as "hours" | "rate"] = parseFloat(value) || 0;
items[idx].amount = items[idx].hours * items[idx].rate;
}
} else if (field === "date") {
if (items[idx]) {
items[idx][field as "date"] = value;
}
} else {
if (items[idx]) {
items[idx][field as "description"] = value;
}
}
return { ...prev, items };
});
};
// Add new item
const addItem = () => {
@@ -98,16 +120,30 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
...prev,
items: [
...prev.items,
{ date: new Date(), description: "", hours: 0, rate: 0, amount: 0 },
{ id: crypto.randomUUID(), date: new Date(), description: "", hours: 0, rate: defaultRate, amount: 0 },
],
}));
};
// Remove item
const removeItem = (idx: number) => {
if (formData.items.length > 1) {
setFormData((prev) => ({
...prev,
items: prev.items.filter((_, i) => i !== idx),
}));
}
};
// Apply default rate to all items
const applyDefaultRate = () => {
setFormData((prev) => ({
...prev,
items: prev.items.filter((_, i) => i !== idx),
items: prev.items.map(item => ({
...item,
rate: defaultRate,
amount: item.hours * defaultRate,
})),
}));
};
@@ -135,13 +171,45 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
// Handle form submit
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate form
if (!formData.businessId) {
toast.error("Please select a business");
return;
}
if (!formData.clientId) {
toast.error("Please select a client");
return;
}
if (formData.items.some(item => !item.description.trim())) {
toast.error("Please fill in all item descriptions");
return;
}
if (formData.items.some(item => item.hours <= 0)) {
toast.error("Please enter valid hours for all items");
return;
}
if (formData.items.some(item => item.rate <= 0)) {
toast.error("Please enter valid rates for all items");
return;
}
setLoading(true);
try {
// In the handleSubmit, ensure items are sent in the current array order with no sorting
const submitData = {
...formData,
items: formData.items.map(item => ({
...item,
items: formData.items.map((item) => ({
date: new Date(item.date),
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
// position will be set by backend based on array order
})),
};
@@ -161,40 +229,150 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
// Show loading state while fetching existing invoice data
if (invoiceId && loadingInvoice) {
return (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
<CardContent>
<div className="space-y-8">
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700">
<FileText className="h-5 w-5" />
<h3 className="text-lg font-semibold">Invoice Details</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse md:col-span-2"></div>
<div className="space-y-6 pb-20">
{/* Invoice Details Card Skeleton */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<div className="h-6 bg-gray-300 rounded w-48 animate-pulse"></div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="h-4 bg-gray-300 rounded w-24 animate-pulse"></div>
<div className="h-10 bg-gray-300 rounded animate-pulse"></div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Invoice Items Card Skeleton */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<div className="flex items-center justify-between">
<div className="h-6 bg-gray-300 rounded w-32 animate-pulse"></div>
<div className="h-10 bg-gray-300 rounded w-24 animate-pulse"></div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Items Table Header Skeleton */}
<div className="grid grid-cols-12 gap-2 px-4 py-3 bg-gray-50 rounded-lg">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-4 bg-gray-300 rounded animate-pulse"></div>
))}
</div>
{/* Items Skeleton */}
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center p-4 border border-gray-200 rounded-lg animate-pulse">
{Array.from({ length: 8 }).map((_, j) => (
<div key={j} className="h-10 bg-gray-300 rounded"></div>
))}
</div>
))}
</div>
</CardContent>
</Card>
{/* Form Controls Bar Skeleton */}
<div className="mt-6">
<div className="bg-white/90 rounded-2xl border border-gray-200 shadow-sm p-4">
<div className="flex items-center justify-between">
<div className="h-4 bg-gray-300 rounded w-32 animate-pulse"></div>
<div className="flex items-center gap-3">
<div className="h-10 bg-gray-300 rounded w-20 animate-pulse"></div>
<div className="h-10 bg-gray-300 rounded w-32 animate-pulse"></div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
const selectedClient = clients?.find(c => c.id === formData.clientId);
const selectedBusiness = businesses?.find(b => b.id === formData.businessId);
// Show loading state while fetching clients
if (loadingClients) {
return (
<div className="space-y-6 pb-20">
{/* Invoice Details Card Skeleton */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<div className="h-6 bg-gray-300 rounded w-48 animate-pulse"></div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="h-4 bg-gray-300 rounded w-24 animate-pulse"></div>
<div className="h-10 bg-gray-300 rounded animate-pulse"></div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Invoice Items Card Skeleton */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<div className="flex items-center justify-between">
<div className="h-6 bg-gray-300 rounded w-32 animate-pulse"></div>
<div className="h-10 bg-gray-300 rounded w-24 animate-pulse"></div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Items Table Header Skeleton */}
<div className="grid grid-cols-12 gap-2 px-4 py-3 bg-gray-50 rounded-lg">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-4 bg-gray-300 rounded animate-pulse"></div>
))}
</div>
{/* Items Skeleton */}
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center p-4 border border-gray-200 rounded-lg animate-pulse">
{Array.from({ length: 8 }).map((_, j) => (
<div key={j} className="h-10 bg-gray-300 rounded"></div>
))}
</div>
))}
</div>
</CardContent>
</Card>
{/* Form Controls Bar Skeleton */}
<div className="mt-6">
<div className="bg-white/90 rounded-2xl border border-gray-200 shadow-sm p-4">
<div className="flex items-center justify-between">
<div className="h-4 bg-gray-300 rounded w-32 animate-pulse"></div>
<div className="flex items-center gap-3">
<div className="h-10 bg-gray-300 rounded w-20 animate-pulse"></div>
<div className="h-10 bg-gray-300 rounded w-32 animate-pulse"></div>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
<CardContent>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Invoice Details */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700">
<form id="invoice-form" onSubmit={handleSubmit} className="space-y-6 pb-20">
{/* Invoice Details Card */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-700">
<FileText className="h-5 w-5" />
<h3 className="text-lg font-semibold">Invoice Details</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
Invoice Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
<div className="space-y-2">
<Label htmlFor="invoiceNumber" className="text-sm font-medium text-gray-700">
Invoice Number
@@ -202,183 +380,311 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Input
id="invoiceNumber"
value={formData.invoiceNumber}
className="h-12 border-gray-200 bg-gray-50"
className="h-10 border-gray-200 bg-gray-50"
placeholder="Auto-generated"
readOnly
/>
</div>
<div className="space-y-2">
<Label htmlFor="businessId" className="text-sm font-medium text-gray-700">
Business *
</Label>
<SearchableSelect
value={formData.businessId}
onValueChange={(value) => setFormData(f => ({ ...f, businessId: value }))}
options={businesses?.map(business => ({ value: business.id, label: business.name })) ?? []}
placeholder="Select a business"
searchPlaceholder="Search businesses..."
disabled={loadingBusinesses}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clientId" className="text-sm font-medium text-gray-700">
Client
Client *
</Label>
<select
id="clientId"
<SearchableSelect
value={formData.clientId}
onChange={e => setFormData(f => ({ ...f, clientId: e.target.value }))}
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
required
onValueChange={(value) => setFormData(f => ({ ...f, clientId: value }))}
options={clients?.map(client => ({ value: client.id, label: client.name })) ?? []}
placeholder="Select a client"
searchPlaceholder="Search clients..."
disabled={loadingClients}
>
<option value="">Select a client</option>
{clients?.map(client => (
<option key={client.id} value={client.id}>{client.name}</option>
))}
</select>
/>
</div>
<div className="space-y-2">
<Label htmlFor="status" className="text-sm font-medium text-gray-700">
Status
</Label>
<Select
value={formData.status}
onValueChange={(value) => setFormData(f => ({ ...f, status: value as "draft" | "sent" | "paid" | "overdue" }))}
>
<SelectTrigger className="h-10 border-gray-200 bg-gray-50">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="sent">Sent</SelectItem>
<SelectItem value="paid">Paid</SelectItem>
<SelectItem value="overdue">Overdue</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="issueDate" className="text-sm font-medium text-gray-700">
Issue Date
Issue Date *
</Label>
<DatePicker
date={formData.issueDate}
onDateChange={date => setFormData(f => ({ ...f, issueDate: date || new Date() }))}
onDateChange={date => setFormData(f => ({ ...f, issueDate: date ?? new Date() }))}
placeholder="Select issue date"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="dueDate" className="text-sm font-medium text-gray-700">
Due Date
Due Date *
</Label>
<DatePicker
date={formData.dueDate}
onDateChange={date => setFormData(f => ({ ...f, dueDate: date || new Date() }))}
onDateChange={date => setFormData(f => ({ ...f, dueDate: date ?? new Date() }))}
placeholder="Select due date"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="status" className="text-sm font-medium text-gray-700">
Status
<Label htmlFor="defaultRate" className="text-sm font-medium text-gray-700">
Default Rate ($/hr)
</Label>
<select
id="status"
value={formData.status}
onChange={e => setFormData(f => ({ ...f, status: e.target.value as "draft" | "sent" | "paid" | "overdue" }))}
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
required
<div className="flex gap-2">
<Input
id="defaultRate"
type="number"
step="0.01"
value={defaultRate}
onChange={e => setDefaultRate(parseFloat(e.target.value) || 0)}
placeholder="0.00"
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
<Button
type="button"
onClick={applyDefaultRate}
variant="outline"
size="sm"
className="h-10 border-emerald-200 text-emerald-700 hover:bg-emerald-50"
>
{STATUS_OPTIONS.map(status => (
<option key={status} value={status}>{status.charAt(0).toUpperCase() + status.slice(1)}</option>
))}
</select>
Apply
</Button>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="notes" className="text-sm font-medium text-gray-700">
Notes
</div>
<div className="space-y-2">
<Label htmlFor="taxRate" className="text-sm font-medium text-gray-700">
Tax Rate (%)
</Label>
<Input
id="notes"
value={formData.notes}
onChange={e => setFormData(f => ({ ...f, notes: e.target.value }))}
placeholder="Additional notes (optional)"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
id="taxRate"
type="number"
step="0.01"
min="0"
max="100"
value={formData.taxRate}
onChange={e => setFormData(f => ({ ...f, taxRate: parseFloat(e.target.value) || 0 }))}
placeholder="0.00"
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
/>
</div>
</div>
</div>
{/* Invoice Items */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700">
<User className="h-5 w-5" />
<h3 className="text-lg font-semibold">Invoice Items</h3>
{selectedBusiness && (
<div className="p-4 bg-emerald-50 rounded-lg border border-emerald-200">
<div className="flex items-center gap-2 text-emerald-700 mb-2">
<Building className="h-4 w-4" />
<span className="font-medium">Business Information</span>
</div>
<div className="text-sm text-gray-700">
<p className="font-medium">{selectedBusiness.name}</p>
{selectedBusiness.email && <p>{selectedBusiness.email}</p>}
{selectedBusiness.phone && <p>{selectedBusiness.phone}</p>}
{selectedBusiness.addressLine1 && (
<p>{selectedBusiness.addressLine1}</p>
)}
{(selectedBusiness.city ?? selectedBusiness.state ?? selectedBusiness.postalCode) && (
<p>
{[selectedBusiness.city, selectedBusiness.state, selectedBusiness.postalCode]
.filter(Boolean)
.join(", ")}
</p>
)}
</div>
</div>
<div className="space-y-4">
{formData.items.map((item, idx) => (
<div key={idx} className="grid grid-cols-1 md:grid-cols-5 gap-4 items-end bg-emerald-50/30 rounded-lg p-4">
<div className="space-y-1">
<Label>Date</Label>
<Input
type="date"
value={format(item.date, "yyyy-MM-dd")}
onChange={e => handleItemChange(idx, "date", new Date(e.target.value))}
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
required
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label>Description</Label>
<Input
value={item.description}
onChange={e => handleItemChange(idx, "description", e.target.value)}
placeholder="Description"
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
required
/>
</div>
<div className="space-y-1">
<Label>Hours</Label>
<Input
type="number"
min={0}
step={0.1}
value={item.hours}
onChange={e => handleItemChange(idx, "hours", e.target.value)}
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
required
/>
</div>
<div className="space-y-1">
<Label>Rate</Label>
<Input
type="number"
min={0}
step={0.01}
value={item.rate}
onChange={e => handleItemChange(idx, "rate", e.target.value)}
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
required
/>
</div>
<div className="space-y-1">
<Label>Amount</Label>
<Input
value={item.amount.toFixed(2)}
readOnly
className="h-10 border-gray-200 bg-gray-100 text-gray-700"
/>
</div>
<div className="flex items-center justify-center md:col-span-5">
{formData.items.length > 1 && (
<Button type="button" variant="destructive" size="sm" onClick={() => removeItem(idx)}>
<Trash2 className="h-4 w-4 mr-1" /> Remove
</Button>
)}
</div>
</div>
))}
<Button type="button" variant="outline" onClick={addItem} className="w-full md:w-auto">
<Plus className="mr-2 h-4 w-4" /> Add Item
</Button>
)}
{selectedClient && (
<div className="p-4 bg-emerald-50 rounded-lg border border-emerald-200">
<div className="flex items-center gap-2 text-emerald-700 mb-2">
<User className="h-4 w-4" />
<span className="font-medium">Client Information</span>
</div>
<div className="text-sm text-gray-700">
<p className="font-medium">{selectedClient.name}</p>
{selectedClient.email && <p>{selectedClient.email}</p>}
{selectedClient.phone && <p>{selectedClient.phone}</p>}
</div>
</div>
</div>
)}
{/* Total Amount */}
<div className="flex justify-end items-center text-lg font-semibold text-emerald-700">
Total: ${totalAmount.toFixed(2)}
</div>
<div className="space-y-2">
<Label htmlFor="notes" className="text-sm font-medium text-gray-700">
Notes
</Label>
<textarea
id="notes"
value={formData.notes}
onChange={e => setFormData(f => ({ ...f, notes: e.target.value }))}
className="w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500 min-h-[80px] resize-none"
placeholder="Additional notes, terms, or special instructions..."
/>
</div>
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-4 pt-6 border-t border-gray-200">
<Button
type="submit"
disabled={loading || (!!invoiceId && loadingInvoice)}
className="flex-1 h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
{/* Invoice Items Card */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-emerald-700">
<Clock className="h-5 w-5" />
Invoice Items
</CardTitle>
<Button
type="button"
onClick={addItem}
variant="outline"
className="border-emerald-200 text-emerald-700 hover:bg-emerald-50"
>
{loading ? "Saving..." : invoiceId ? "Update Invoice" : "Create Invoice"}
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Items Table Header */}
<div className="grid grid-cols-12 gap-2 px-4 py-3 bg-gray-50 rounded-lg font-medium text-sm text-gray-700 items-center">
<div className="col-span-1 text-center"></div>
<div className="col-span-2">Date</div>
<div className="col-span-4">Description</div>
<div className="col-span-1">Hours</div>
<div className="col-span-2">Rate ($)</div>
<div className="col-span-1">Amount</div>
<div className="col-span-1"></div>
</div>
{/* Items */}
<EditableInvoiceItems
items={formData.items}
onItemsChange={(newItems) => setFormData(prev => ({ ...prev, items: newItems }))}
onRemoveItem={removeItem}
/>
{/* Validation Messages */}
{formData.items.some(item => !item.description.trim()) && (
<div className="flex items-center gap-2 text-amber-600 text-sm">
<AlertCircle className="h-4 w-4" />
Please fill in all item descriptions
</div>
)}
{formData.items.some(item => item.hours <= 0) && (
<div className="flex items-center gap-2 text-amber-600 text-sm">
<AlertCircle className="h-4 w-4" />
Please enter valid hours for all items
</div>
)}
{formData.items.some(item => item.rate <= 0) && (
<div className="flex items-center gap-2 text-amber-600 text-sm">
<AlertCircle className="h-4 w-4" />
Please enter valid rates for all items
</div>
)}
<Separator />
{/* Totals */}
<div className="flex justify-end">
<div className="text-right space-y-2">
<div className="space-y-1">
<div className="text-sm text-gray-600">Subtotal: ${totals.subtotal.toFixed(2)}</div>
{formData.taxRate > 0 && (
<div className="text-sm text-gray-600">
Tax ({formData.taxRate}%): ${totals.taxAmount.toFixed(2)}
</div>
)}
</div>
<div className="text-lg font-medium text-gray-700">Total Amount</div>
<div className="text-3xl font-bold text-emerald-600">${totals.total.toFixed(2)}</div>
<div className="text-sm text-gray-500">
{formData.items.length} item{formData.items.length !== 1 ? 's' : ''}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Form Controls Bar */}
<div className="mt-6">
<div className="bg-white/90 rounded-2xl border border-gray-200 shadow-sm p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-gray-600">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-emerald-500 rounded-full"></div>
<span>Ready to save</span>
</div>
{formData.items.length > 0 && (
<span className="text-gray-400"></span>
)}
{formData.items.length > 0 && (
<span>{formData.items.length} item{formData.items.length !== 1 ? 's' : ''}</span>
)}
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
className="flex-1 w-full h-12 border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
onClick={() => router.push(invoiceId ? `/dashboard/invoices/${invoiceId}` : "/dashboard/invoices")}
onClick={() => router.push("/dashboard/invoices")}
className="border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
>
Cancel
</Button>
<Button
type="submit"
disabled={loading}
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
{loading ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{invoiceId ? "Updating..." : "Creating..."}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{invoiceId ? "Update Invoice" : "Create Invoice"}
</>
)}
</Button>
</div>
</div>
</div>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -4,30 +4,56 @@ import { useState } from "react";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { Calendar, FileText, User, DollarSign, Trash2, Edit, Download, Send } from "lucide-react";
import {
Calendar,
FileText,
User,
DollarSign,
Trash2,
Edit,
Download,
Send,
ArrowLeft,
Clock,
MapPin,
Mail,
Phone,
AlertCircle,
} from "lucide-react";
import Link from "next/link";
import { generateInvoicePDF } from "~/lib/pdf-export";
import { InvoiceViewSkeleton } from "~/components/ui/skeleton";
interface InvoiceViewProps {
invoiceId: string;
}
const statusColors = {
draft: "bg-gray-100 text-gray-800",
sent: "bg-blue-100 text-blue-800",
paid: "bg-green-100 text-green-800",
overdue: "bg-red-100 text-red-800",
} as const;
const statusLabels = {
draft: "Draft",
sent: "Sent",
paid: "Paid",
overdue: "Overdue",
const statusConfig = {
draft: { label: "Draft", color: "bg-gray-100 text-gray-800", icon: FileText },
sent: { label: "Sent", color: "bg-blue-100 text-blue-800", icon: Send },
paid: {
label: "Paid",
color: "bg-green-100 text-green-800",
icon: DollarSign,
},
overdue: {
label: "Overdue",
color: "bg-red-100 text-red-800",
icon: AlertCircle,
},
} as const;
export function InvoiceView({ invoiceId }: InvoiceViewProps) {
@@ -36,7 +62,11 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
const [isExportingPDF, setIsExportingPDF] = useState(false);
// Fetch invoice data
const { data: invoice, isLoading, refetch } = api.invoices.getById.useQuery({ id: invoiceId });
const {
data: invoice,
isLoading,
refetch,
} = api.invoices.getById.useQuery({ id: invoiceId });
// Delete mutation
const deleteInvoice = api.invoices.delete.useMutation({
@@ -69,13 +99,15 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
deleteInvoice.mutate({ id: invoiceId });
};
const handleStatusUpdate = (newStatus: "draft" | "sent" | "paid" | "overdue") => {
const handleStatusUpdate = (
newStatus: "draft" | "sent" | "paid" | "overdue",
) => {
updateStatus.mutate({ id: invoiceId, status: newStatus });
};
const handlePDFExport = async () => {
if (!invoice) return;
setIsExportingPDF(true);
try {
await generateInvoicePDF(invoice);
@@ -99,31 +131,26 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
return format(new Date(date), "MMM dd, yyyy");
};
const isOverdue =
invoice &&
new Date(invoice.dueDate) < new Date() &&
invoice.status !== "paid";
if (isLoading) {
return (
<div className="space-y-6">
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<div className="h-8 bg-gray-200 rounded animate-pulse"></div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
</div>
</CardContent>
</Card>
</div>
);
return <InvoiceViewSkeleton />;
}
if (!invoice) {
return (
<div className="text-center py-12">
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Invoice not found</h3>
<p className="text-gray-500 mb-4">The invoice you're looking for doesn't exist or has been deleted.</p>
<div className="py-12 text-center">
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900">
Invoice not found
</h3>
<p className="mb-4 text-gray-500">
The invoice you&apos;re looking for doesn&apos;t exist or has been
deleted.
</p>
<Button asChild>
<Link href="/dashboard/invoices">Back to Invoices</Link>
</Button>
@@ -131,99 +158,151 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
);
}
const StatusIcon =
statusConfig[invoice.status as keyof typeof statusConfig].icon;
return (
<div className="space-y-6">
{/* Invoice Header */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<CardTitle className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<FileText className="h-6 w-6 text-emerald-600" />
{invoice.invoiceNumber}
</CardTitle>
<p className="text-gray-600 mt-1">Created on {formatDate(invoice.createdAt)}</p>
{/* Status Alert */}
{isOverdue && (
<Card className="border-red-200 bg-red-50">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-red-700">
<AlertCircle className="h-5 w-5" />
<span className="font-medium">This invoice is overdue</span>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusColors[invoice.status as keyof typeof statusColors]}`}>
{statusLabels[invoice.status as keyof typeof statusLabels]}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleStatusUpdate("sent")}
disabled={invoice.status === "sent" || updateStatus.isLoading}
>
<Send className="h-4 w-4 mr-1" />
Mark Sent
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleStatusUpdate("paid")}
disabled={invoice.status === "paid" || updateStatus.isLoading}
>
<DollarSign className="h-4 w-4 mr-1" />
Mark Paid
</Button>
<Button
variant="outline"
size="sm"
onClick={handlePDFExport}
disabled={isExportingPDF}
>
<Download className="h-4 w-4 mr-1" />
{isExportingPDF ? "Generating..." : "Export PDF"}
</Button>
</div>
</div>
</div>
</CardHeader>
</Card>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Invoice Header Card */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
<CardContent>
<div className="flex items-start justify-between">
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-emerald-100 p-2">
<FileText className="h-6 w-6 text-emerald-600" />
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900">
{invoice.invoiceNumber}
</h2>
<p className="text-gray-600">Professional Invoice</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6 text-sm">
<div>
<span className="text-gray-500">Issue Date</span>
<p className="font-medium text-gray-900">
{formatDate(invoice.issueDate)}
</p>
</div>
<div>
<span className="text-gray-500">Due Date</span>
<p className="font-medium text-gray-900">
{formatDate(invoice.dueDate)}
</p>
</div>
</div>
</div>
<div className="space-y-3 text-right">
<Badge
className={`${statusConfig[invoice.status as keyof typeof statusConfig].color} px-3 py-1 text-sm font-medium`}
>
<StatusIcon className="mr-1 h-3 w-3" />
{
statusConfig[invoice.status as keyof typeof statusConfig]
.label
}
</Badge>
<div className="text-3xl font-bold text-emerald-600">
{formatCurrency(invoice.totalAmount)}
</div>
<Button
onClick={handlePDFExport}
disabled={isExportingPDF}
className="transform-none bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg transition-shadow duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
>
{isExportingPDF ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
Generating PDF...
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Download PDF
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Invoice Details */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Details */}
<div className="lg:col-span-2 space-y-6">
{/* Client Information */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<User className="h-5 w-5 text-emerald-600" />
Client Information
<CardTitle className="flex items-center gap-2 text-emerald-700">
<User className="h-5 w-5" />
Bill To
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-gray-700">Client Name</label>
<p className="text-gray-900 font-medium">{invoice.client?.name}</p>
</div>
<CardContent className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">
{invoice.client?.name}
</h3>
</div>
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
{invoice.client?.email && (
<div>
<label className="text-sm font-medium text-gray-700">Email</label>
<p className="text-gray-900">{invoice.client.email}</p>
<div className="flex items-center gap-2 text-gray-600">
<Mail className="h-4 w-4 text-gray-400" />
{invoice.client.email}
</div>
)}
{invoice.client?.phone && (
<div>
<label className="text-sm font-medium text-gray-700">Phone</label>
<p className="text-gray-900">{invoice.client.phone}</p>
<div className="flex items-center gap-2 text-gray-600">
<Phone className="h-4 w-4 text-gray-400" />
{invoice.client.phone}
</div>
)}
{(invoice.client?.addressLine1 || invoice.client?.city || invoice.client?.state) && (
<div>
<label className="text-sm font-medium text-gray-700">Address</label>
<p className="text-gray-900">
{[
invoice.client?.addressLine1,
invoice.client?.addressLine2,
invoice.client?.city,
invoice.client?.state,
invoice.client?.postalCode,
].filter(Boolean).join(", ")}
</p>
{(invoice.client?.addressLine1 ??
invoice.client?.city ??
invoice.client?.state) && (
<div className="flex items-start gap-2 text-gray-600 md:col-span-2">
<MapPin className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-400" />
<div>
{invoice.client?.addressLine1 && (
<div>{invoice.client.addressLine1}</div>
)}
{invoice.client?.addressLine2 && (
<div>{invoice.client.addressLine2}</div>
)}
{(invoice.client?.city ??
invoice.client?.state ??
invoice.client?.postalCode) && (
<div>
{[
invoice.client?.city,
invoice.client?.state,
invoice.client?.postalCode,
]
.filter(Boolean)
.join(", ")}
</div>
)}
{invoice.client?.country && (
<div>{invoice.client.country}</div>
)}
</div>
</div>
)}
</div>
@@ -231,122 +310,178 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</Card>
{/* Invoice Items */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg font-semibold text-gray-900">Invoice Items</CardTitle>
<CardTitle className="flex items-center gap-2 text-emerald-700">
<Clock className="h-5 w-5" />
Invoice Items
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Date</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Description</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">Hours</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">Rate</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">Amount</th>
<div className="overflow-hidden rounded-lg border border-gray-200">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">
Date
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">
Description
</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700">
Hours
</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700">
Rate
</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700">
Amount
</th>
</tr>
</thead>
<tbody>
{invoice.items?.map((item, index) => (
<tr key={index} className="border-b border-gray-100 hover:bg-emerald-50/30 transition-colors">
<td className="py-3 px-4 text-gray-900">{formatDate(item.date)}</td>
<td className="py-3 px-4 text-gray-900">{item.description}</td>
<td className="py-3 px-4 text-gray-900 text-right">{item.hours}</td>
<td className="py-3 px-4 text-gray-900 text-right">{formatCurrency(item.rate)}</td>
<td className="py-3 px-4 text-gray-900 font-semibold text-right">{formatCurrency(item.amount)}</td>
<tr
key={item.id || index}
className="border-t border-gray-100 hover:bg-gray-50"
>
<td className="px-4 py-3 text-sm text-gray-900">
{formatDate(item.date)}
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{item.description}
</td>
<td className="px-4 py-3 text-right text-sm text-gray-900">
{item.hours}
</td>
<td className="px-4 py-3 text-right text-sm text-gray-900">
{formatCurrency(item.rate)}
</td>
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900">
{formatCurrency(item.amount)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-emerald-200 bg-emerald-50/50">
<td colSpan={4} className="py-4 px-4 text-right font-semibold text-gray-900">Total:</td>
<td className="py-4 px-4 text-right font-bold text-emerald-600 text-lg">{formatCurrency(invoice.totalAmount)}</td>
</tr>
</tfoot>
</table>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Invoice Summary */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Calendar className="h-5 w-5 text-emerald-600" />
Invoice Summary
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-700">Issue Date</label>
<p className="text-gray-900">{formatDate(invoice.issueDate)}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-700">Due Date</label>
<p className="text-gray-900">{formatDate(invoice.dueDate)}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-700">Status</label>
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${statusColors[invoice.status as keyof typeof statusColors]}`}>
{statusLabels[invoice.status as keyof typeof statusLabels]}
</span>
</div>
<div className="pt-4 border-t border-gray-200">
<label className="text-lg font-semibold text-gray-900">Total Amount</label>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(invoice.totalAmount)}</p>
</div>
</div>
</CardContent>
</Card>
{/* Notes */}
{invoice.notes && (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg font-semibold text-gray-900">Notes</CardTitle>
<CardTitle className="text-emerald-700">Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 whitespace-pre-wrap">{invoice.notes}</p>
<p className="whitespace-pre-wrap text-gray-700">
{invoice.notes}
</p>
</CardContent>
</Card>
)}
</div>
{/* Actions */}
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
{/* Sidebar */}
<div className="space-y-6">
{/* Status Actions */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg font-semibold text-gray-900">Actions</CardTitle>
<CardTitle className="text-emerald-700">Status Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{invoice.status === "draft" && (
<Button
onClick={() => handleStatusUpdate("sent")}
disabled={updateStatus.isPending}
className="w-full bg-blue-600 text-white hover:bg-blue-700"
>
<Send className="mr-2 h-4 w-4" />
Mark as Sent
</Button>
)}
{invoice.status === "sent" && (
<Button
onClick={() => handleStatusUpdate("paid")}
disabled={updateStatus.isPending}
className="w-full bg-green-600 text-white hover:bg-green-700"
>
<DollarSign className="mr-2 h-4 w-4" />
Mark as Paid
</Button>
)}
{invoice.status === "overdue" && (
<Button
onClick={() => handleStatusUpdate("paid")}
disabled={updateStatus.isPending}
className="w-full bg-green-600 text-white hover:bg-green-700"
>
<DollarSign className="mr-2 h-4 w-4" />
Mark as Paid
</Button>
)}
{invoice.status === "paid" && (
<div className="py-4 text-center">
<DollarSign className="mx-auto mb-2 h-8 w-8 text-green-600" />
<p className="font-medium text-green-600">Invoice Paid</p>
</div>
)}
</CardContent>
</Card>
{/* Invoice Summary */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-emerald-700">Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Subtotal</span>
<span className="font-medium">
{formatCurrency(invoice.totalAmount)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Tax</span>
<span className="font-medium">$0.00</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold">
<span>Total</span>
<span className="text-emerald-600">
{formatCurrency(invoice.totalAmount)}
</span>
</div>
</div>
<div className="border-t border-gray-200 pt-4 text-center">
<p className="text-sm text-gray-500">
{invoice.items?.length ?? 0} item
{invoice.items?.length !== 1 ? "s" : ""}
</p>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-0 border-red-200 bg-white/80 shadow-xl backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-red-700">Danger Zone</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<Button asChild className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium">
<Link href={`/dashboard/invoices/${invoiceId}/edit`}>
<Edit className="h-4 w-4 mr-2" />
Edit Invoice
</Link>
</Button>
<Button
variant="outline"
className="w-full border-gray-300 text-gray-700 hover:bg-gray-50"
onClick={handlePDFExport}
disabled={isExportingPDF}
>
<Download className="h-4 w-4 mr-2" />
{isExportingPDF ? "Generating PDF..." : "Download PDF"}
</Button>
<Button
variant="destructive"
className="w-full bg-red-600 hover:bg-red-700"
onClick={handleDelete}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Invoice
</Button>
</div>
<Button
onClick={handleDelete}
variant="outline"
className="w-full border-red-200 text-red-700 hover:bg-red-50"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Invoice
</Button>
</CardContent>
</Card>
</div>
@@ -354,32 +489,36 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="bg-white/95 backdrop-blur-sm border-0 shadow-2xl">
<DialogContent className="border-0 bg-white/95 shadow-2xl backdrop-blur-sm">
<DialogHeader>
<DialogTitle className="text-xl font-bold text-gray-800">Delete Invoice</DialogTitle>
<DialogTitle className="text-xl font-bold text-gray-800">
Delete Invoice
</DialogTitle>
<DialogDescription className="text-gray-600">
Are you sure you want to delete this invoice? This action cannot be undone.
Are you sure you want to delete this invoice? This action cannot
be undone and will permanently remove the invoice and all its
data.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
className="border-gray-300 text-gray-700 hover:bg-gray-50"
>
Cancel
</Button>
<Button
variant="destructive"
<Button
variant="destructive"
onClick={confirmDelete}
disabled={deleteInvoice.isPending}
className="bg-red-600 hover:bg-red-700"
disabled={deleteInvoice.isLoading}
>
{deleteInvoice.isLoading ? "Deleting..." : "Delete"}
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
}

View File

@@ -1,40 +1,27 @@
import Image from "next/image";
import { cn } from "~/lib/utils";
import { CircleDollarSign } from "lucide-react";
interface LogoProps {
className?: string;
size?: "sm" | "md" | "lg";
showIcon?: boolean;
}
export function Logo({ className, size = "md" }: LogoProps) {
const sizeClasses = {
sm: "text-lg",
md: "text-2xl",
lg: "text-4xl",
sm: { width: 120, height: 32 },
md: { width: 160, height: 42 },
lg: { width: 240, height: 64 },
};
const { width, height } = sizeClasses[size];
return (
<div className={cn("flex items-center space-x-2", className)}>
<CircleDollarSign className="w-6 h-6 text-green-500"/>
<div className="flex items-center">
<span
className={cn(
"bg-gradient-to-r from-green-600 via-green-700 to-emerald-700 bg-clip-text font-bold tracking-tight text-transparent",
sizeClasses[size],
)}
>
been
</span>
<span
className={cn(
"font-semibold tracking-wide text-gray-800",
sizeClasses[size],
)}
>
voice
</span>
</div>
</div>
<Image
src="/beenvoice-logo.svg"
alt="beenvoice logo"
width={width}
height={height}
className={className}
priority
/>
);
}

View File

@@ -52,7 +52,7 @@ export function DatePicker({
id={id}
disabled={disabled}
className={cn(
"w-full justify-between font-normal h-9 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 text-sm",
"w-full justify-between font-normal h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 text-sm",
!date && "text-gray-500"
)}
>

View File

@@ -0,0 +1,356 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, Search } from "lucide-react"
import { cn } from "~/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 h-10 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-emerald-500 focus-visible:ring-emerald-500 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
// Enhanced SelectContent with search functionality
function SelectContentWithSearch({
className,
children,
position = "popper",
searchPlaceholder = "Search...",
onSearchChange,
searchValue,
isOpen,
filteredOptions,
...props
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
searchPlaceholder?: string;
onSearchChange?: (value: string) => void;
searchValue?: string;
isOpen?: boolean;
filteredOptions?: { value: string; label: string }[];
}) {
const searchInputRef = React.useRef<HTMLInputElement>(null);
const wasOpen = React.useRef(false);
React.useEffect(() => {
// Only focus when dropdown transitions from closed to open
if (isOpen && !wasOpen.current && searchInputRef.current) {
searchInputRef.current.focus();
}
wasOpen.current = !!isOpen;
}, [isOpen]);
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
onEscapeKeyDown={(e) => {
// Prevent escape from closing the dropdown when typing
if (searchValue) {
e.preventDefault();
}
}}
onPointerDownOutside={(e) => {
// Prevent closing when clicking inside the search input
if (searchInputRef.current?.contains(e.target as Node)) {
e.preventDefault();
}
}}
{...props}
>
{onSearchChange && (
<div className="flex items-center px-3 py-2 border-b">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
ref={searchInputRef}
className="flex h-8 w-full rounded-md bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 border-0 focus:ring-0 focus:outline-none"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={(e) => {
// Prevent the dropdown from closing when typing
if (e.key === "Escape") {
e.stopPropagation();
}
// Prevent arrow keys from moving focus away from search
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
e.stopPropagation();
}
}}
onFocus={(e) => {
// Ensure the search input stays focused
e.target.select();
}}
autoFocus
/>
</div>
)}
<SelectScrollUpButton />
<SelectPrimitive.Viewport className="p-1">
{filteredOptions && filteredOptions.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground select-none">No results found</div>
) : (
children
)}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
// Searchable Select component
interface SearchableSelectProps {
value?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
options: { value: string; label: string }[];
searchPlaceholder?: string;
className?: string;
disabled?: boolean;
}
function SearchableSelect({
value,
onValueChange,
placeholder,
options,
searchPlaceholder = "Search...",
className,
disabled
}: SearchableSelectProps) {
const [searchValue, setSearchValue] = React.useState("");
const [isOpen, setIsOpen] = React.useState(false);
const filteredOptions = React.useMemo(() => {
if (!searchValue) return options;
return options.filter(option =>
option.label.toLowerCase().includes(searchValue.toLowerCase())
);
}, [options, searchValue]);
// Convert empty string to placeholder value for display
const displayValue = value === "" ? "__placeholder__" : value;
// Convert placeholder value back to empty string when selected
const handleValueChange = (newValue: string) => {
const actualValue = newValue === "__placeholder__" ? "" : newValue;
onValueChange?.(actualValue);
// Clear search when an option is selected
setSearchValue("");
setIsOpen(false);
};
return (
<Select
value={displayValue}
onValueChange={handleValueChange}
disabled={disabled}
open={isOpen}
onOpenChange={setIsOpen}
>
<SelectTrigger className={cn("w-full", className)}>
<SelectValue
placeholder={placeholder}
// Always show placeholder if nothing is selected
data-placeholder={displayValue === "__placeholder__"}
/>
</SelectTrigger>
<SelectContentWithSearch
searchPlaceholder={searchPlaceholder}
searchValue={searchValue}
onSearchChange={setSearchValue}
isOpen={isOpen}
filteredOptions={filteredOptions}
>
{filteredOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContentWithSearch>
</Select>
);
}
export {
Select,
SelectContent,
SelectContentWithSearch,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
SearchableSelect,
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "~/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -4,10 +4,212 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
className={cn("bg-muted animate-pulse rounded-md", className)}
{...props}
/>
)
}
// Dashboard skeleton components
export function DashboardStatsSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="shadow-xl border-0 bg-white/80 backdrop-blur-sm rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
<Skeleton className="h-8 w-16 mb-2" />
<Skeleton className="h-3 w-32" />
</div>
))}
</div>
)
}
export function DashboardCardsSkeleton() {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="shadow-xl border-0 bg-white/80 backdrop-blur-sm rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-6 w-32" />
</div>
<Skeleton className="h-4 w-full mb-4" />
<div className="flex gap-3">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-32" />
</div>
</div>
))}
</div>
)
}
export function DashboardActivitySkeleton() {
return (
<div className="shadow-xl border-0 bg-white/80 backdrop-blur-sm rounded-xl p-6">
<Skeleton className="h-6 w-32 mb-6" />
<div className="text-center py-12">
<Skeleton className="h-20 w-20 rounded-full mx-auto mb-4" />
<Skeleton className="h-6 w-48 mx-auto mb-2" />
<Skeleton className="h-4 w-64 mx-auto" />
</div>
</div>
)
}
// Table skeleton components
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="space-y-4">
{/* Search and filters */}
<div className="flex flex-col sm:flex-row gap-4">
<Skeleton className="h-10 w-64" />
<div className="flex gap-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-24" />
</div>
</div>
{/* Table */}
<div className="border rounded-lg">
<div className="p-4 border-b">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<div className="flex gap-2">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
</div>
</div>
</div>
<div className="p-4">
<div className="space-y-3">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
</div>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<div className="flex gap-2">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
</div>
</div>
</div>
)
}
// Form skeleton components
export function FormSkeleton() {
return (
<div className="space-y-6">
<div className="space-y-4">
<div>
<Skeleton className="h-4 w-20 mb-2" />
<Skeleton className="h-10 w-full" />
</div>
<div>
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="h-10 w-full" />
</div>
<div>
<Skeleton className="h-4 w-16 mb-2" />
<Skeleton className="h-10 w-full" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Skeleton className="h-4 w-20 mb-2" />
<Skeleton className="h-10 w-full" />
</div>
<div>
<Skeleton className="h-4 w-16 mb-2" />
<Skeleton className="h-10 w-full" />
</div>
</div>
<div className="flex gap-3">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-24" />
</div>
</div>
)
}
// Invoice view skeleton
export function InvoiceViewSkeleton() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-start">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-10 w-32" />
</div>
{/* Client info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
<div className="space-y-3">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
{/* Items table */}
<div className="border rounded-lg">
<div className="p-4 border-b">
<Skeleton className="h-5 w-32" />
</div>
<div className="p-4">
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
</div>
</div>
{/* Total */}
<div className="flex justify-end">
<div className="space-y-2">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-8 w-40" />
</div>
</div>
</div>
)
}
export { Skeleton }

File diff suppressed because it is too large Load Diff