mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-05 08:16:31 -05:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
src/components/SidebarTrigger.tsx
Normal file
81
src/components/SidebarTrigger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
454
src/components/business-form.tsx
Normal file
454
src/components/business-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
232
src/components/dark-mode-test.tsx
Normal file
232
src/components/dark-mode-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
src/components/dark-mode-toggle.tsx
Normal file
143
src/components/dark-mode-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
282
src/components/editable-invoice-items.tsx
Normal file
282
src/components/editable-invoice-items.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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're looking for doesn'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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
356
src/components/ui/select.tsx
Normal file
356
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
31
src/components/ui/separator.tsx
Normal file
31
src/components/ui/separator.tsx
Normal 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 }
|
||||
@@ -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
Reference in New Issue
Block a user